newspack-components 4.6.2 → 4.7.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.
Files changed (148) hide show
  1. package/CHANGELOG.md +23 -0
  2. package/colors/colors.module.scss +13 -7
  3. package/dist/cjs/autocomplete-tokenfield/style.scss +4 -2
  4. package/dist/cjs/breadcrumbs/format-count.js +33 -0
  5. package/dist/cjs/breadcrumbs/index.js +78 -20
  6. package/dist/cjs/breadcrumbs/index.test.js +110 -0
  7. package/dist/cjs/breadcrumbs/style.scss +6 -0
  8. package/dist/cjs/button/index.js +41 -17
  9. package/dist/cjs/button/index.test.js +273 -0
  10. package/dist/cjs/card/core-card.js +9 -4
  11. package/dist/cjs/card/core-card.test.js +29 -0
  12. package/dist/cjs/card/index.js +4 -2
  13. package/dist/cjs/card/style-core.scss +59 -0
  14. package/dist/cjs/card-feature/index.js +7 -1
  15. package/dist/cjs/card-feature/index.test.js +92 -0
  16. package/dist/cjs/color-picker/index.js +1 -1
  17. package/dist/cjs/confirm-dialog/index.js +26 -9
  18. package/dist/cjs/confirm-dialog/index.test.js +176 -0
  19. package/dist/cjs/dataviews/index.js +11 -2
  20. package/dist/cjs/divider/index.js +13 -0
  21. package/dist/cjs/drawer/action.js +74 -0
  22. package/dist/cjs/drawer/body.js +79 -0
  23. package/dist/cjs/drawer/close-icon.js +45 -0
  24. package/dist/cjs/drawer/content.js +72 -0
  25. package/dist/cjs/drawer/context.js +23 -0
  26. package/dist/cjs/drawer/context.test.js +43 -0
  27. package/dist/cjs/drawer/divider.js +24 -0
  28. package/dist/cjs/drawer/footer.js +26 -0
  29. package/dist/cjs/drawer/header.js +26 -0
  30. package/dist/cjs/drawer/index.js +42 -0
  31. package/dist/cjs/drawer/index.test.js +1287 -0
  32. package/dist/cjs/drawer/root.js +350 -0
  33. package/dist/cjs/drawer/slots.test.js +422 -0
  34. package/dist/cjs/drawer/style.scss +205 -0
  35. package/dist/cjs/drawer/title.js +54 -0
  36. package/dist/cjs/drawer/types.js +5 -0
  37. package/dist/cjs/drawer/use-exit-animation.js +59 -0
  38. package/dist/cjs/hooks/use-confirm-dialog.js +15 -3
  39. package/dist/cjs/hooks/use-unsaved-changes-dialog.js +19 -4
  40. package/dist/cjs/index.js +28 -1
  41. package/dist/cjs/page/index.js +1 -1
  42. package/dist/cjs/page-control/index.js +82 -0
  43. package/dist/cjs/plugin-settings/SettingsSection.js +7 -1
  44. package/dist/cjs/section-header/index.js +5 -2
  45. package/dist/cjs/section-header/style.scss +25 -12
  46. package/dist/cjs/table-card/index.js +67 -0
  47. package/dist/cjs/table-card/index.test.js +153 -0
  48. package/dist/cjs/with-wizard-screen/style.scss +8 -0
  49. package/dist/cjs/wizard/index.js +102 -38
  50. package/dist/esm/autocomplete-tokenfield/style.scss +4 -2
  51. package/dist/esm/breadcrumbs/format-count.js +27 -0
  52. package/dist/esm/breadcrumbs/index.js +81 -20
  53. package/dist/esm/breadcrumbs/index.test.js +110 -0
  54. package/dist/esm/breadcrumbs/style.scss +6 -0
  55. package/dist/esm/button/index.js +42 -18
  56. package/dist/esm/button/index.test.js +273 -0
  57. package/dist/esm/card/core-card.js +9 -4
  58. package/dist/esm/card/core-card.test.js +29 -0
  59. package/dist/esm/card/index.js +4 -2
  60. package/dist/esm/card/style-core.scss +59 -0
  61. package/dist/esm/card-feature/index.js +8 -2
  62. package/dist/esm/card-feature/index.test.js +96 -0
  63. package/dist/esm/color-picker/index.js +1 -1
  64. package/dist/esm/confirm-dialog/index.js +26 -9
  65. package/dist/esm/confirm-dialog/index.test.js +176 -0
  66. package/dist/esm/dataviews/index.js +11 -2
  67. package/dist/esm/divider/index.js +14 -0
  68. package/dist/esm/drawer/action.js +68 -0
  69. package/dist/esm/drawer/body.js +70 -0
  70. package/dist/esm/drawer/close-icon.js +37 -0
  71. package/dist/esm/drawer/content.js +63 -0
  72. package/dist/esm/drawer/context.js +17 -0
  73. package/dist/esm/drawer/context.test.js +40 -0
  74. package/dist/esm/drawer/divider.js +16 -0
  75. package/dist/esm/drawer/footer.js +18 -0
  76. package/dist/esm/drawer/header.js +18 -0
  77. package/dist/esm/drawer/index.js +35 -0
  78. package/dist/esm/drawer/index.test.js +1285 -0
  79. package/dist/esm/drawer/root.js +342 -0
  80. package/dist/esm/drawer/slots.test.js +422 -0
  81. package/dist/esm/drawer/style.scss +205 -0
  82. package/dist/esm/drawer/title.js +46 -0
  83. package/dist/esm/drawer/types.js +1 -0
  84. package/dist/esm/drawer/use-exit-animation.js +52 -0
  85. package/dist/esm/hooks/use-confirm-dialog.js +15 -3
  86. package/dist/esm/hooks/use-unsaved-changes-dialog.js +20 -5
  87. package/dist/esm/index.js +4 -0
  88. package/dist/esm/page/index.js +1 -1
  89. package/dist/esm/page-control/index.js +82 -0
  90. package/dist/esm/plugin-settings/SettingsSection.js +8 -2
  91. package/dist/esm/section-header/index.js +5 -2
  92. package/dist/esm/section-header/style.scss +25 -12
  93. package/dist/esm/table-card/index.js +60 -0
  94. package/dist/esm/table-card/index.test.js +150 -0
  95. package/dist/esm/with-wizard-screen/style.scss +8 -0
  96. package/dist/esm/wizard/index.js +104 -38
  97. package/package.json +2 -2
  98. package/src/autocomplete-tokenfield/style.scss +4 -2
  99. package/src/breadcrumbs/format-count.js +27 -0
  100. package/src/breadcrumbs/index.js +68 -8
  101. package/src/breadcrumbs/index.test.js +57 -0
  102. package/src/breadcrumbs/style.scss +6 -0
  103. package/src/button/index.test.js +143 -0
  104. package/src/button/index.tsx +32 -7
  105. package/src/card/core-card.js +15 -2
  106. package/src/card/core-card.test.js +19 -0
  107. package/src/card/index.js +3 -0
  108. package/src/card/style-core.scss +59 -0
  109. package/src/card-feature/README.md +4 -0
  110. package/src/card-feature/index.test.js +65 -0
  111. package/src/card-feature/index.tsx +13 -2
  112. package/src/color-picker/index.js +1 -1
  113. package/src/confirm-dialog/README.md +3 -2
  114. package/src/confirm-dialog/index.test.js +146 -0
  115. package/src/confirm-dialog/index.tsx +23 -9
  116. package/src/dataviews/index.tsx +11 -2
  117. package/src/divider/index.js +13 -0
  118. package/src/drawer/README.md +454 -0
  119. package/src/drawer/action.tsx +67 -0
  120. package/src/drawer/body.tsx +60 -0
  121. package/src/drawer/close-icon.tsx +37 -0
  122. package/src/drawer/content.tsx +54 -0
  123. package/src/drawer/context.test.js +31 -0
  124. package/src/drawer/context.ts +25 -0
  125. package/src/drawer/divider.tsx +13 -0
  126. package/src/drawer/footer.tsx +15 -0
  127. package/src/drawer/header.tsx +15 -0
  128. package/src/drawer/index.test.js +1012 -0
  129. package/src/drawer/index.tsx +33 -0
  130. package/src/drawer/root.tsx +328 -0
  131. package/src/drawer/slots.test.js +314 -0
  132. package/src/drawer/style.scss +205 -0
  133. package/src/drawer/title.tsx +40 -0
  134. package/src/drawer/types.ts +79 -0
  135. package/src/drawer/use-exit-animation.ts +46 -0
  136. package/src/hooks/use-confirm-dialog.tsx +14 -3
  137. package/src/hooks/use-unsaved-changes-dialog.tsx +17 -5
  138. package/src/index.js +4 -0
  139. package/src/page/index.js +1 -1
  140. package/src/page-control/index.js +63 -0
  141. package/src/plugin-settings/SettingsSection.js +8 -2
  142. package/src/section-header/index.js +5 -2
  143. package/src/section-header/style.scss +25 -12
  144. package/src/table-card/README.md +100 -0
  145. package/src/table-card/index.test.js +112 -0
  146. package/src/table-card/index.tsx +56 -0
  147. package/src/with-wizard-screen/style.scss +8 -0
  148. package/src/wizard/index.js +95 -34
@@ -10,9 +10,10 @@ import classnames from 'classnames';
10
10
  /**
11
11
  * WordPress dependencies.
12
12
  */
13
- import { DropdownMenu, MenuGroup, MenuItem } from '@wordpress/components';
13
+ // Notice is aliased: `Notice` below is Newspack's own, which this file also uses.
14
+ import { DropdownMenu, MenuGroup, MenuItem, Notice as CoreNotice, SlotFillProvider, createSlotFill } from '@wordpress/components';
14
15
  import { useDispatch, useSelect } from '@wordpress/data';
15
- import { cloneElement, isValidElement, useEffect, useState, forwardRef } from '@wordpress/element';
16
+ import { cloneElement, createInterpolateElement, isValidElement, useEffect, useState, forwardRef } from '@wordpress/element';
16
17
  import { __ } from '@wordpress/i18n';
17
18
  import { category, chevronLeft, moreVertical } from '@wordpress/icons';
18
19
 
@@ -28,6 +29,15 @@ import WizardError from './components/WizardError';
28
29
  import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
29
30
  registerStore();
30
31
 
32
+ /**
33
+ * Renders a view's page-level banner outside the padded content column, so it sits
34
+ * flush beneath the header rather than indented within the section it describes.
35
+ */
36
+ var _createSlotFill = createSlotFill('NewspackWizardBanner'),
37
+ WizardBannerSlot = _createSlotFill.Slot,
38
+ WizardBanner = _createSlotFill.Fill;
39
+ export { WizardBanner };
40
+
31
41
  /**
32
42
  * Icon registry for resolving icon name strings passed through the data store.
33
43
  * React elements from @wordpress/icons can't cross webpack entry point boundaries
@@ -50,6 +60,34 @@ var HashRouter = Router.HashRouter,
50
60
  Switch = Router.Switch,
51
61
  useLocation = Router.useLocation;
52
62
 
63
+ /**
64
+ * Interpolate a translated message's named tags, falling back to plain text.
65
+ *
66
+ * The message is translated, so its tags are site-controlled: any .mo file under
67
+ * wp-content/languages/plugins, any GlotPress export, or any `gettext`-filter
68
+ * plugin can supply one. `createInterpolateElement` throws on an unbalanced
69
+ * closing tag whose name is in the conversion map - verified against core's own
70
+ * element.js, which is what runs here since the bundle externalizes wp-element.
71
+ * Nothing above this renders an error boundary, so an uncaught throw would blank
72
+ * the whole wizard, including the screen the inert-gating notice links to as the
73
+ * fix. Degraded copy is recoverable; a blank screen is not.
74
+ *
75
+ * The other malformed cases already degrade on their own and are left alone: an
76
+ * unclosed opener drops that tag and renders the rest as text, and a tag not in
77
+ * the map renders literally.
78
+ *
79
+ * @param {string} message Translated message carrying named tags.
80
+ * @param {Object} conversion Conversion map for createInterpolateElement.
81
+ * @return {JSX.Element|string} The interpolated message, or the message with its tags stripped.
82
+ */
83
+ var interpolateOrPlainText = function interpolateOrPlainText(message, conversion) {
84
+ try {
85
+ return createInterpolateElement(message, conversion);
86
+ } catch (_unused) {
87
+ return message.replace(/<\/?[a-zA-Z][a-zA-Z0-9]*\s*\/?>/g, '');
88
+ }
89
+ };
90
+
53
91
  /**
54
92
  * Reset the header data when a new section is rendered.
55
93
  */
@@ -109,7 +147,7 @@ var WizardHeaderRegion = function WizardHeaderRegion(_ref) {
109
147
 
110
148
  /**
111
149
  * @typedef {Object} WizardProps
112
- * @property {string} headerText The header text.
150
+ * @property {string} [headerText] Fallback heading, used only when no section declares breadcrumbs.
113
151
  * @property {string} [subHeaderText] The sub-header text, optional.
114
152
  * @property {string} [apiSlug] The API slug, optional.
115
153
  * @property {string} [className] CSS classes, optional.
@@ -129,6 +167,7 @@ var WizardHeaderRegion = function WizardHeaderRegion(_ref) {
129
167
  * @return {JSX.Element} Wizard component
130
168
  */
131
169
  var Wizard = function Wizard(_ref2, ref) {
170
+ var _window$newspack_aux_;
132
171
  var _ref2$sections = _ref2.sections,
133
172
  sections = _ref2$sections === void 0 ? [] : _ref2$sections,
134
173
  headerText = _ref2.headerText,
@@ -208,10 +247,35 @@ var Wizard = function Wizard(_ref2, ref) {
208
247
  items: displayedSections,
209
248
  children: /*#__PURE__*/_jsx(WizardError, {})
210
249
  });
250
+
251
+ // Rendered here rather than as a core admin notice, which wizards strip at
252
+ // priority -9999. Sits as the first child of .newspack-wizard__main so it lands
253
+ // flush beneath the header region and spans the full width in every view: this
254
+ // describes the state of the whole site, not of the section below it, so it reads
255
+ // as page chrome rather than as content.
256
+ var inertGating = (_window$newspack_aux_ = window.newspack_aux_data) === null || _window$newspack_aux_ === void 0 ? void 0 : _window$newspack_aux_.inert_gating;
257
+ var inertGatingNotice = (inertGating === null || inertGating === void 0 ? void 0 : inertGating.show) && /*#__PURE__*/_jsx(CoreNotice, {
258
+ status: "warning",
259
+ isDismissible: false,
260
+ className: "newspack-wizard__inert-gating-notice",
261
+ children: interpolateOrPlainText(inertGating.message, {
262
+ /* eslint-disable jsx-a11y/anchor-has-content */
263
+ accessControl: /*#__PURE__*/_jsx("a", {
264
+ href: inertGating.urls.accessControl
265
+ }),
266
+ audience: /*#__PURE__*/_jsx("a", {
267
+ href: inertGating.urls.audience
268
+ }),
269
+ /* eslint-enable jsx-a11y/anchor-has-content */
270
+ strong: /*#__PURE__*/_jsx("strong", {})
271
+ })
272
+ });
211
273
  var content = /*#__PURE__*/_jsxs(_Fragment, {
212
- children: [/*#__PURE__*/_jsx(HandoffMessage, {}), sections.length > 1 && /*#__PURE__*/_jsx(ResetHeaderData, {}), /*#__PURE__*/_jsx("div", {
274
+ children: [/*#__PURE__*/_jsx(HandoffMessage, {}), sections.length > 1 && /*#__PURE__*/_jsx(ResetHeaderData, {}), /*#__PURE__*/_jsxs("div", {
213
275
  className: "newspack-wizard__main",
214
- children: /*#__PURE__*/_jsxs(Switch, {
276
+ children: [inertGatingNotice, /*#__PURE__*/_jsx(WizardBannerSlot, {
277
+ bubblesVirtually: true
278
+ }), /*#__PURE__*/_jsxs(Switch, {
215
279
  children: [routedSections.map(function (section, index) {
216
280
  var _section$exact;
217
281
  var SectionComponent = section.render;
@@ -242,7 +306,7 @@ var Wizard = function Wizard(_ref2, ref) {
242
306
  }), /*#__PURE__*/_jsx(Redirect, {
243
307
  to: displayedSections[0].path
244
308
  })]
245
- })
309
+ })]
246
310
  })]
247
311
  });
248
312
  var headerActions = (actions === null || actions === void 0 ? void 0 : actions.length) > 0 ? /*#__PURE__*/_jsxs(_Fragment, {
@@ -293,40 +357,42 @@ var Wizard = function Wizard(_ref2, ref) {
293
357
  }
294
358
  })]
295
359
  }) : undefined;
296
- return /*#__PURE__*/_jsxs("div", {
297
- ref: ref,
298
- children: [/*#__PURE__*/_jsxs("div", {
299
- className: classnames(isLoading ? 'newspack-wizard__is-loading' : 'newspack-wizard__is-loaded', {
300
- 'newspack-wizard__is-loading-quiet': isQuietLoading
301
- }),
302
- children: [/*#__PURE__*/_jsxs(HashRouter, {
303
- hashType: "slash",
304
- children: [newspack_aux_data.is_debug_mode && /*#__PURE__*/_jsx(Notice, {
305
- debugMode: true
306
- }), /*#__PURE__*/_jsx(WizardHeaderRegion, {
307
- hideHeader: hideHeader,
308
- headerText: headerText,
309
- sections: routedSections,
310
- sectionName: sectionName,
311
- subTitle: subHeaderText,
312
- actions: headerActions,
313
- tabbedNavigation: tabbedNavigation,
314
- children: content
360
+ return /*#__PURE__*/_jsx(SlotFillProvider, {
361
+ children: /*#__PURE__*/_jsxs("div", {
362
+ ref: ref,
363
+ children: [/*#__PURE__*/_jsxs("div", {
364
+ className: classnames(isLoading ? 'newspack-wizard__is-loading' : 'newspack-wizard__is-loaded', {
365
+ 'newspack-wizard__is-loading-quiet': isQuietLoading
366
+ }),
367
+ children: [/*#__PURE__*/_jsxs(HashRouter, {
368
+ hashType: "slash",
369
+ children: [newspack_aux_data.is_debug_mode && /*#__PURE__*/_jsx(Notice, {
370
+ debugMode: true
371
+ }), /*#__PURE__*/_jsx(WizardHeaderRegion, {
372
+ hideHeader: hideHeader,
373
+ headerText: headerText,
374
+ sections: routedSections,
375
+ sectionName: sectionName,
376
+ subTitle: subHeaderText,
377
+ actions: headerActions,
378
+ tabbedNavigation: tabbedNavigation,
379
+ children: content
380
+ })]
381
+ }), (notices === null || notices === void 0 ? void 0 : notices.length) > 0 && /*#__PURE__*/_jsx("div", {
382
+ className: "newspack-wizard__snackbar-list",
383
+ children: notices.map(function (notice, index) {
384
+ return /*#__PURE__*/_jsx(WizardSnackbar, {
385
+ id: notice.id,
386
+ type: notice.type,
387
+ actions: notice.actions,
388
+ children: notice.message
389
+ }, notice.id || index);
390
+ })
315
391
  })]
316
- }), (notices === null || notices === void 0 ? void 0 : notices.length) > 0 && /*#__PURE__*/_jsx("div", {
317
- className: "newspack-wizard__snackbar-list",
318
- children: notices.map(function (notice, index) {
319
- return /*#__PURE__*/_jsx(WizardSnackbar, {
320
- id: notice.id,
321
- type: notice.type,
322
- actions: notice.actions,
323
- children: notice.message
324
- }, notice.id || index);
325
- })
392
+ }), !isLoading && /*#__PURE__*/_jsx(Footer, {
393
+ simple: hasSimpleFooter
326
394
  })]
327
- }), !isLoading && /*#__PURE__*/_jsx(Footer, {
328
- simple: hasSimpleFooter
329
- })]
395
+ })
330
396
  });
331
397
  };
332
398
  export default forwardRef(Wizard);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "newspack-components",
3
- "version": "4.6.2",
3
+ "version": "4.7.0-alpha.1",
4
4
  "description": "Newspack design system components",
5
5
  "author": "Automattic",
6
6
  "license": "GPL-2.0-or-later",
@@ -30,7 +30,7 @@
30
30
  "classnames": "^2.3.1",
31
31
  "lodash": "^4.17.21",
32
32
  "moment-range": "^3.1.1",
33
- "newspack-icons": "1.1.1",
33
+ "newspack-icons": "1.1.2-alpha.1",
34
34
  "qs": "^6.15.2",
35
35
  "react-router-dom": "^5.3.4",
36
36
  "@babel/runtime": "^7.29.7",
@@ -3,6 +3,7 @@
3
3
  */
4
4
 
5
5
  @use "~@wordpress/base-styles/colors" as wp-colors;
6
+ @use "~@wordpress/base-styles/variables" as wp-vars;
6
7
 
7
8
  .newspack-autocomplete-tokenfield {
8
9
  .components-spinner {
@@ -34,8 +35,9 @@
34
35
 
35
36
  .newspack-autocomplete-tokenfield__help {
36
37
  color: wp-colors.$gray-700;
37
- font-size: inherit;
38
- margin-top: 0;
38
+ font-size: wp-vars.$helptext-font-size;
39
+ line-height: wp-vars.$default-line-height;
40
+ margin: wp-vars.$grid-unit-10 0 0;
39
41
  }
40
42
 
41
43
  .components-form-token-field__suggestions-list {
@@ -0,0 +1,27 @@
1
+ /**
2
+ * Group a number for the site's locale rather than the browser's, so a crumb's count
3
+ * matches both the translated phrasing beside it and every server-rendered figure on
4
+ * the page. Callers that build their own `countLabel` share this so the visible count
5
+ * and the spoken one are grouped the same way.
6
+ *
7
+ * WordPress ships locales `Intl` rejects outright — `pt_PT_ao90` yields the BCP-47
8
+ * tag `pt-PT-ao90`, whose four-character variant subtag must start with a digit — so
9
+ * the tag is shortened a subtag at a time until one is accepted. Falling straight
10
+ * through to the browser's locale would group `1,234` beside a server-rendered
11
+ * `1.234`, which is the divergence this helper exists to prevent.
12
+ *
13
+ * @param {number} count Number to format.
14
+ * @return {string} The grouped number.
15
+ */
16
+ export const formatCount = count => {
17
+ const lang = ( typeof document !== 'undefined' && document.documentElement.lang ) || '';
18
+ const subtags = lang.replace( /_/g, '-' ).split( '-' ).filter( Boolean );
19
+ while ( subtags.length ) {
20
+ try {
21
+ return Number( count ).toLocaleString( subtags.join( '-' ) );
22
+ } catch {
23
+ subtags.pop();
24
+ }
25
+ }
26
+ return Number( count ).toLocaleString();
27
+ };
@@ -1,14 +1,63 @@
1
1
  /**
2
2
  * WordPress dependencies.
3
3
  */
4
- import { __ } from '@wordpress/i18n';
4
+ import { __, _n, sprintf } from '@wordpress/i18n';
5
5
  import { Link, Stack, Text } from '@wordpress/ui';
6
6
 
7
7
  /**
8
8
  * Internal dependencies.
9
9
  */
10
+ import { formatCount } from './format-count';
10
11
  import './style.scss';
11
12
 
13
+ /**
14
+ * A crumb's count, shown in parentheses and spoken as a phrase.
15
+ *
16
+ * @param {Object} props
17
+ * @param {number} [props.count] Item count.
18
+ * @param {string} [props.countLabel] Translated accessible phrasing for the count.
19
+ * @return {JSX.Element|null} The count, or null when the crumb carries none.
20
+ */
21
+ const CrumbCount = ( { count, countLabel } ) => {
22
+ if ( ! Number.isFinite( count ) ) {
23
+ return null;
24
+ }
25
+ const formatted = formatCount( count );
26
+ return (
27
+ <span className="newspack-breadcrumbs__count">
28
+ <span aria-hidden="true">{ `(${ formatted })` }</span>
29
+ <span className="screen-reader-text">
30
+ { countLabel ||
31
+ sprintf(
32
+ /* translators: %s: number of items in the current view. */
33
+ _n( '%s item', '%s items', count, 'newspack-plugin' ),
34
+ formatted
35
+ ) }
36
+ </span>
37
+ </span>
38
+ );
39
+ };
40
+
41
+ /**
42
+ * A crumb's label, with its count appended when the item carries one.
43
+ *
44
+ * @param {Object} props
45
+ * @param {string} props.label Crumb label.
46
+ * @param {number} [props.count] Item count to annotate the label with.
47
+ * @param {string} [props.countLabel] Translated accessible phrasing for the count.
48
+ * @return {JSX.Element|string} The label, annotated when a count is present.
49
+ */
50
+ const CrumbLabel = ( { label, count, countLabel } ) => {
51
+ if ( ! Number.isFinite( count ) ) {
52
+ return label;
53
+ }
54
+ return (
55
+ <>
56
+ { label } <CrumbCount count={ count } countLabel={ countLabel } />
57
+ </>
58
+ );
59
+ };
60
+
12
61
  /**
13
62
  * Newspack breadcrumb trail.
14
63
  *
@@ -18,7 +67,7 @@ import './style.scss';
18
67
  * component needs no router context.
19
68
  *
20
69
  * @param {Object} props
21
- * @param {Array} props.items Trail items: `{ label, url? }`.
70
+ * @param {Array} props.items Trail items: `{ label, url?, count?, countLabel? }`.
22
71
  * @return {JSX.Element|null} Breadcrumbs component.
23
72
  */
24
73
  const Breadcrumbs = ( { items = [] } ) => {
@@ -35,12 +84,23 @@ const Breadcrumbs = ( { items = [] } ) => {
35
84
  { preceding.map( ( item, index ) => (
36
85
  <li key={ item.url || index }>
37
86
  { item.url ? (
38
- // eslint-disable-next-line jsx-a11y/anchor-has-content -- content is supplied via the Text children through @wordpress/ui's render prop.
39
- <Text variant="body-lg" render={ <Link tone="neutral" render={ <a href={ item.url } /> } /> }>
40
- { item.label }
41
- </Text>
87
+ <>
88
+ { /* eslint-disable-next-line jsx-a11y/anchor-has-content -- content is supplied via the Text children through @wordpress/ui's render prop. */ }
89
+ <Text variant="body-lg" render={ <Link tone="neutral" render={ <a href={ item.url } /> } /> }>
90
+ { item.label }
91
+ </Text>
92
+ { /* Outside the link: the count describes the view being left, not the destination. */ }
93
+ { Number.isFinite( item.count ) && (
94
+ <Text variant="body-lg">
95
+ { ' ' }
96
+ <CrumbCount count={ item.count } countLabel={ item.countLabel } />
97
+ </Text>
98
+ ) }
99
+ </>
42
100
  ) : (
43
- <Text variant="body-lg">{ item.label }</Text>
101
+ <Text variant="body-lg">
102
+ <CrumbLabel { ...item } />
103
+ </Text>
44
104
  ) }
45
105
  <Text variant="body-lg" aria-hidden="true" className="newspack-breadcrumbs__separator">
46
106
  /
@@ -51,7 +111,7 @@ const Breadcrumbs = ( { items = [] } ) => {
51
111
  <li>
52
112
  { /* eslint-disable-next-line jsx-a11y/heading-has-content -- content is supplied via the Text children through @wordpress/ui's render prop. */ }
53
113
  <Text variant="heading-lg" render={ <h1 /> } className="newspack-breadcrumbs__current">
54
- { last.label }
114
+ <CrumbLabel { ...last } />
55
115
  </Text>
56
116
  </li>
57
117
  ) }
@@ -9,6 +9,11 @@ import { render, screen } from '@testing-library/react';
9
9
  import Breadcrumbs from './';
10
10
 
11
11
  describe( 'Breadcrumbs', () => {
12
+ // Unconditional, so a failing assertion cannot leak a language into later tests.
13
+ afterEach( () => {
14
+ document.documentElement.lang = '';
15
+ } );
16
+
12
17
  it( 'renders the last item as the only h1 and never a link', () => {
13
18
  render( <Breadcrumbs items={ [ { label: 'Newsletters' }, { label: 'Settings', url: '#/x' } ] } /> );
14
19
  const h1s = screen.getAllByRole( 'heading', { level: 1 } );
@@ -56,4 +61,56 @@ describe( 'Breadcrumbs', () => {
56
61
  const { container } = render( <Breadcrumbs items={ [] } /> );
57
62
  expect( container.querySelector( 'nav' ) ).not.toBeInTheDocument();
58
63
  } );
64
+
65
+ it( 'annotates a crumb with its count, hiding the parens from assistive tech', () => {
66
+ const { container } = render( <Breadcrumbs items={ [ { label: 'Plans' }, { label: 'Subscriptions', count: 33 } ] } /> );
67
+ const count = container.querySelector( '.newspack-breadcrumbs__count' );
68
+ expect( count ).not.toBeNull();
69
+ expect( count.querySelector( '[aria-hidden="true"]' ) ).toHaveTextContent( '(33)' );
70
+ expect( count.querySelector( '.screen-reader-text' ) ).toHaveTextContent( '33 items' );
71
+ expect( screen.getByRole( 'heading', { level: 1 } ) ).toContainElement( count );
72
+ } );
73
+
74
+ it( 'announces a single item in the singular', () => {
75
+ const { container } = render( <Breadcrumbs items={ [ { label: 'Donations', count: 1 } ] } /> );
76
+ expect( container.querySelector( '.newspack-breadcrumbs__count .screen-reader-text' ).textContent ).toBe( '1 item' );
77
+ } );
78
+
79
+ it( 'renders a zero count rather than dropping the annotation', () => {
80
+ const { container } = render( <Breadcrumbs items={ [ { label: 'Donations', count: 0 } ] } /> );
81
+ expect( container.querySelector( '.newspack-breadcrumbs__count [aria-hidden="true"]' ) ).toHaveTextContent( '(0)' );
82
+ } );
83
+
84
+ it( 'omits the annotation when a crumb carries no usable count', () => {
85
+ const { container } = render( <Breadcrumbs items={ [ { label: 'Plans' }, { label: 'Bundles', count: undefined } ] } /> );
86
+ expect( container.querySelector( '.newspack-breadcrumbs__count' ) ).toBeNull();
87
+ expect( screen.getByRole( 'heading', { level: 1 } ) ).toHaveTextContent( 'Bundles' );
88
+ } );
89
+
90
+ it( 'prefers a supplied countLabel for the accessible phrasing', () => {
91
+ const { container } = render( <Breadcrumbs items={ [ { label: 'Subscribers', count: 85, countLabel: '85 subscribers total' } ] } /> );
92
+ expect( container.querySelector( '.newspack-breadcrumbs__count .screen-reader-text' ) ).toHaveTextContent( '85 subscribers total' );
93
+ } );
94
+
95
+ it( 'annotates a linked crumb outside the link, so the link is named for its destination', () => {
96
+ const { container } = render( <Breadcrumbs items={ [ { label: 'Plans', url: '#/', count: 12 }, { label: 'Add plan' } ] } /> );
97
+ const link = screen.getByRole( 'link', { name: 'Plans' } );
98
+ expect( link.getAttribute( 'href' ) ).toBe( '#/' );
99
+ expect( link ).not.toHaveTextContent( '(12)' );
100
+ const count = container.querySelector( '.newspack-breadcrumbs__count' );
101
+ expect( count.querySelector( '[aria-hidden="true"]' ) ).toHaveTextContent( '(12)' );
102
+ expect( link ).not.toContainElement( count );
103
+ } );
104
+
105
+ it( 'groups the count for the document language', () => {
106
+ document.documentElement.lang = 'de-DE';
107
+ const { container } = render( <Breadcrumbs items={ [ { label: 'Abonnenten', count: 1234 } ] } /> );
108
+ expect( container.querySelector( '.newspack-breadcrumbs__count [aria-hidden="true"]' ) ).toHaveTextContent( '(1.234)' );
109
+ } );
110
+
111
+ it( 'falls back to the default grouping when the document language is not a locale Intl accepts', () => {
112
+ document.documentElement.lang = 'pt-PT-ao90';
113
+ const { container } = render( <Breadcrumbs items={ [ { label: 'Planos', count: 1234 } ] } /> );
114
+ expect( container.querySelector( '.newspack-breadcrumbs__count [aria-hidden="true"]' ).textContent ).toMatch( /^\(1\D?234\)$/ );
115
+ } );
59
116
  } );
@@ -1,4 +1,5 @@
1
1
  @use "~@wordpress/base-styles/colors" as wp-colors;
2
+ @use "~@wordpress/base-styles/variables" as wp-vars;
2
3
 
3
4
  .newspack-breadcrumbs__list {
4
5
  list-style: none;
@@ -26,6 +27,11 @@
26
27
  }
27
28
  }
28
29
 
30
+ .newspack-breadcrumbs__count {
31
+ color: var(--wpds-color-foreground-content-neutral-weak, #{wp-colors.$gray-700});
32
+ font-weight: wp-vars.$font-weight-regular;
33
+ }
34
+
29
35
  .newspack-breadcrumbs__separator {
30
36
  color: var(--wpds-color-stroke-surface-neutral, #dbdbdb);
31
37
  margin: 0 var(--wpds-dimension-gap-sm, 8px);
@@ -0,0 +1,143 @@
1
+ /**
2
+ * External dependencies.
3
+ */
4
+ import { act, render, screen } from '@testing-library/react';
5
+ import { MemoryRouter, useHistory } from 'react-router-dom';
6
+
7
+ /**
8
+ * Internal dependencies.
9
+ */
10
+ import Button from './';
11
+
12
+ const HistoryGrabber = ( { historyRef } ) => {
13
+ historyRef.current = useHistory();
14
+ return null;
15
+ };
16
+
17
+ const clickButton = async () => {
18
+ await act( async () => {
19
+ screen.getByRole( 'button', { name: 'Save' } ).click();
20
+ } );
21
+ };
22
+
23
+ describe( 'Button href with onClick', () => {
24
+ it( 'awaits the onClick, then pushes the route', async () => {
25
+ const historyRef = { current: null };
26
+ const onClick = jest.fn().mockResolvedValue( undefined );
27
+ render(
28
+ <MemoryRouter>
29
+ <HistoryGrabber historyRef={ historyRef } />
30
+ <Button href="#/next" onClick={ onClick }>
31
+ Save
32
+ </Button>
33
+ </MemoryRouter>
34
+ );
35
+
36
+ await clickButton();
37
+ expect( onClick ).toHaveBeenCalled();
38
+ // The leading '#' is stripped so the router sees a route, not a fragment.
39
+ expect( historyRef.current.location.pathname ).toBe( '/next' );
40
+ } );
41
+ } );
42
+
43
+ // `useHistory` returns undefined outside a wizard, leaving no route table for the
44
+ // href to name.
45
+ describe( 'Button href with onClick outside a router', () => {
46
+ // jsdom's real location logs "Not implemented: navigation" rather than
47
+ // navigating, so swap in a plain object to assert against and put the
48
+ // original back afterwards.
49
+ const withStubbedLocation = async fn => {
50
+ const original = window.location;
51
+ delete window.location;
52
+ window.location = { href: '' };
53
+ try {
54
+ return await fn();
55
+ } finally {
56
+ window.location = original;
57
+ }
58
+ };
59
+
60
+ it( 'awaits the onClick, then navigates to the href', async () => {
61
+ const onClick = jest.fn().mockResolvedValue( undefined );
62
+ await withStubbedLocation( async () => {
63
+ render(
64
+ <Button href="/wp-admin/admin.php?page=next" onClick={ onClick }>
65
+ Save
66
+ </Button>
67
+ );
68
+
69
+ await clickButton();
70
+ expect( onClick ).toHaveBeenCalled();
71
+ expect( window.location.href ).toBe( '/wp-admin/admin.php?page=next' );
72
+ } );
73
+ } );
74
+
75
+ it( 'hands the click event to a combined href and onClick handler', async () => {
76
+ const onClick = jest.fn();
77
+ await withStubbedLocation( async () => {
78
+ render(
79
+ <Button href="/wp-admin/admin.php?page=next" onClick={ onClick }>
80
+ Save
81
+ </Button>
82
+ );
83
+
84
+ await clickButton();
85
+ expect( onClick ).toHaveBeenCalledWith( expect.objectContaining( { type: 'click' } ) );
86
+ } );
87
+ } );
88
+
89
+ it( 'runs an onClick with no href without touching navigation', async () => {
90
+ const onClick = jest.fn();
91
+ await withStubbedLocation( async () => {
92
+ render( <Button onClick={ onClick }>Save</Button> );
93
+
94
+ await clickButton();
95
+ expect( onClick ).toHaveBeenCalled();
96
+ expect( window.location.href ).toBe( '' );
97
+ } );
98
+ } );
99
+
100
+ it( 'drops a javascript: href and still runs the onClick', async () => {
101
+ const warn = jest.spyOn( console, 'warn' ).mockImplementation( () => {} );
102
+ const onClick = jest.fn().mockResolvedValue( undefined );
103
+ await withStubbedLocation( async () => {
104
+ // eslint-disable-next-line no-script-url
105
+ render(
106
+ <Button href="javascript:alert(1)" onClick={ onClick }>
107
+ Save
108
+ </Button>
109
+ );
110
+
111
+ await clickButton();
112
+ expect( onClick ).toHaveBeenCalled();
113
+ expect( window.location.href ).toBe( '' );
114
+ } );
115
+ expect( warn ).toHaveBeenCalledWith( expect.stringContaining( 'javascript:' ) );
116
+ warn.mockRestore();
117
+ } );
118
+ } );
119
+
120
+ describe( 'Button with a javascript: href alone', () => {
121
+ const link = () => screen.getByText( 'Save' ).closest( 'a' );
122
+
123
+ it( 'renders no link', () => {
124
+ const warn = jest.spyOn( console, 'warn' ).mockImplementation( () => {} );
125
+ render( <Button href="JavaScript:alert(1)">Save</Button> );
126
+
127
+ expect( link() ).toBeNull();
128
+ warn.mockRestore();
129
+ } );
130
+
131
+ it( 'sees through interleaved control characters', () => {
132
+ const warn = jest.spyOn( console, 'warn' ).mockImplementation( () => {} );
133
+ render( <Button href={ 'java\nscript:alert(1)' }>Save</Button> );
134
+
135
+ expect( link() ).toBeNull();
136
+ warn.mockRestore();
137
+ } );
138
+
139
+ it( 'leaves an ordinary href alone', () => {
140
+ render( <Button href="/wp-admin/admin.php?page=next">Save</Button> );
141
+ expect( link() ).toHaveAttribute( 'href', '/wp-admin/admin.php?page=next' );
142
+ } );
143
+ } );
@@ -6,7 +6,7 @@
6
6
  * WordPress dependencies.
7
7
  */
8
8
  import { Button as BaseComponent } from '@wordpress/components';
9
- import { useState } from '@wordpress/element';
9
+ import { useEffect, useState } from '@wordpress/element';
10
10
 
11
11
  /**
12
12
  * Internal dependencies
@@ -20,23 +20,48 @@ type OriginalButtonProps = typeof BaseComponent.defaultProps;
20
20
  type Props = OriginalButtonProps & {
21
21
  href?: string;
22
22
  loading?: boolean;
23
- onClick?: () => void;
23
+ onClick?: ( event?: React.MouseEvent< HTMLElement > ) => void;
24
24
  };
25
25
 
26
+ // Control characters are stripped first, as browsers do, so `java\nscript:`
27
+ // cannot slip past.
28
+ const isJavascriptHref = ( href?: string ) =>
29
+ 'string' === typeof href &&
30
+ href
31
+ .replace( /[\u0000-\u0020]/g, '' )
32
+ .toLowerCase()
33
+ .startsWith( 'javascript:' );
34
+
26
35
  const Button = ( { href, loading = undefined, onClick, ...otherProps }: Props ) => {
27
36
  const history = useHistory();
28
37
  const [ isAwaitingOnClick, setIsAwaitingOnClick ] = useState( false );
29
38
 
39
+ const isUnsafeHref = isJavascriptHref( href );
40
+ const safeHref = isUnsafeHref ? undefined : href;
41
+
42
+ useEffect( () => {
43
+ if ( 'production' === process.env.NODE_ENV || ! isUnsafeHref ) {
44
+ return;
45
+ }
46
+ // eslint-disable-next-line no-console
47
+ console.warn( 'Button: a `javascript:` href is not a link and has been dropped.' );
48
+ }, [ isUnsafeHref ] );
49
+
30
50
  // If both onClick and href are present, await the onClick action an then redirect.
31
- if ( href && onClick ) {
32
- ( otherProps as Props ).onClick = async () => {
51
+ if ( safeHref && onClick ) {
52
+ ( otherProps as Props ).onClick = async ( event?: React.MouseEvent< HTMLElement > ) => {
33
53
  setIsAwaitingOnClick( true );
34
- await onClick();
54
+ await onClick( event );
35
55
  setIsAwaitingOnClick( false );
36
- history.push( ( href || '' ).replace( '#', '' ) );
56
+ // Outside a Router the href can only mean a real URL.
57
+ if ( history ) {
58
+ history.push( safeHref.replace( '#', '' ) );
59
+ } else {
60
+ window.location.href = safeHref;
61
+ }
37
62
  };
38
63
  } else {
39
- ( otherProps as Props ).href = href;
64
+ ( otherProps as Props ).href = safeHref;
40
65
  ( otherProps as Props ).onClick = onClick;
41
66
  }
42
67
  if ( isAwaitingOnClick ) {