newspack-components 4.6.3 → 4.7.0-alpha.2
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 +7 -0
- package/colors/colors.module.scss +13 -7
- package/dist/cjs/autocomplete-tokenfield/style.scss +4 -2
- package/dist/cjs/breadcrumbs/format-count.js +33 -0
- package/dist/cjs/breadcrumbs/index.js +78 -20
- package/dist/cjs/breadcrumbs/index.test.js +110 -0
- package/dist/cjs/breadcrumbs/style.scss +6 -0
- package/dist/cjs/button/index.js +41 -17
- package/dist/cjs/button/index.test.js +273 -0
- package/dist/cjs/card/core-card.js +9 -4
- package/dist/cjs/card/core-card.test.js +29 -0
- package/dist/cjs/card/index.js +4 -2
- package/dist/cjs/card/style-core.scss +59 -0
- package/dist/cjs/card-feature/index.js +7 -1
- package/dist/cjs/card-feature/index.test.js +92 -0
- package/dist/cjs/color-picker/index.js +1 -1
- package/dist/cjs/confirm-dialog/index.js +26 -9
- package/dist/cjs/confirm-dialog/index.test.js +176 -0
- package/dist/cjs/dataviews/index.js +11 -2
- package/dist/cjs/divider/index.js +13 -0
- package/dist/cjs/drawer/action.js +74 -0
- package/dist/cjs/drawer/body.js +79 -0
- package/dist/cjs/drawer/close-icon.js +45 -0
- package/dist/cjs/drawer/content.js +72 -0
- package/dist/cjs/drawer/context.js +23 -0
- package/dist/cjs/drawer/context.test.js +43 -0
- package/dist/cjs/drawer/divider.js +24 -0
- package/dist/cjs/drawer/footer.js +26 -0
- package/dist/cjs/drawer/header.js +26 -0
- package/dist/cjs/drawer/index.js +42 -0
- package/dist/cjs/drawer/index.test.js +1287 -0
- package/dist/cjs/drawer/root.js +350 -0
- package/dist/cjs/drawer/slots.test.js +422 -0
- package/dist/cjs/drawer/style.scss +205 -0
- package/dist/cjs/drawer/title.js +54 -0
- package/dist/cjs/drawer/types.js +5 -0
- package/dist/cjs/drawer/use-exit-animation.js +59 -0
- package/dist/cjs/hooks/use-confirm-dialog.js +15 -3
- package/dist/cjs/hooks/use-unsaved-changes-dialog.js +19 -4
- package/dist/cjs/index.js +28 -1
- package/dist/cjs/page/index.js +1 -1
- package/dist/cjs/page-control/index.js +82 -0
- package/dist/cjs/plugin-settings/SettingsSection.js +7 -1
- package/dist/cjs/section-header/index.js +5 -2
- package/dist/cjs/section-header/style.scss +25 -12
- package/dist/cjs/table-card/index.js +67 -0
- package/dist/cjs/table-card/index.test.js +153 -0
- package/dist/cjs/with-wizard-screen/style.scss +8 -0
- package/dist/cjs/wizard/index.js +102 -38
- package/dist/esm/autocomplete-tokenfield/style.scss +4 -2
- package/dist/esm/breadcrumbs/format-count.js +27 -0
- package/dist/esm/breadcrumbs/index.js +81 -20
- package/dist/esm/breadcrumbs/index.test.js +110 -0
- package/dist/esm/breadcrumbs/style.scss +6 -0
- package/dist/esm/button/index.js +42 -18
- package/dist/esm/button/index.test.js +273 -0
- package/dist/esm/card/core-card.js +9 -4
- package/dist/esm/card/core-card.test.js +29 -0
- package/dist/esm/card/index.js +4 -2
- package/dist/esm/card/style-core.scss +59 -0
- package/dist/esm/card-feature/index.js +8 -2
- package/dist/esm/card-feature/index.test.js +96 -0
- package/dist/esm/color-picker/index.js +1 -1
- package/dist/esm/confirm-dialog/index.js +26 -9
- package/dist/esm/confirm-dialog/index.test.js +176 -0
- package/dist/esm/dataviews/index.js +11 -2
- package/dist/esm/divider/index.js +14 -0
- package/dist/esm/drawer/action.js +68 -0
- package/dist/esm/drawer/body.js +70 -0
- package/dist/esm/drawer/close-icon.js +37 -0
- package/dist/esm/drawer/content.js +63 -0
- package/dist/esm/drawer/context.js +17 -0
- package/dist/esm/drawer/context.test.js +40 -0
- package/dist/esm/drawer/divider.js +16 -0
- package/dist/esm/drawer/footer.js +18 -0
- package/dist/esm/drawer/header.js +18 -0
- package/dist/esm/drawer/index.js +35 -0
- package/dist/esm/drawer/index.test.js +1285 -0
- package/dist/esm/drawer/root.js +342 -0
- package/dist/esm/drawer/slots.test.js +422 -0
- package/dist/esm/drawer/style.scss +205 -0
- package/dist/esm/drawer/title.js +46 -0
- package/dist/esm/drawer/types.js +1 -0
- package/dist/esm/drawer/use-exit-animation.js +52 -0
- package/dist/esm/hooks/use-confirm-dialog.js +15 -3
- package/dist/esm/hooks/use-unsaved-changes-dialog.js +20 -5
- package/dist/esm/index.js +4 -0
- package/dist/esm/page/index.js +1 -1
- package/dist/esm/page-control/index.js +82 -0
- package/dist/esm/plugin-settings/SettingsSection.js +8 -2
- package/dist/esm/section-header/index.js +5 -2
- package/dist/esm/section-header/style.scss +25 -12
- package/dist/esm/table-card/index.js +60 -0
- package/dist/esm/table-card/index.test.js +150 -0
- package/dist/esm/with-wizard-screen/style.scss +8 -0
- package/dist/esm/wizard/index.js +104 -38
- package/package.json +2 -2
- package/src/autocomplete-tokenfield/style.scss +4 -2
- package/src/breadcrumbs/format-count.js +27 -0
- package/src/breadcrumbs/index.js +68 -8
- package/src/breadcrumbs/index.test.js +57 -0
- package/src/breadcrumbs/style.scss +6 -0
- package/src/button/index.test.js +143 -0
- package/src/button/index.tsx +32 -7
- package/src/card/core-card.js +15 -2
- package/src/card/core-card.test.js +19 -0
- package/src/card/index.js +3 -0
- package/src/card/style-core.scss +59 -0
- package/src/card-feature/README.md +4 -0
- package/src/card-feature/index.test.js +65 -0
- package/src/card-feature/index.tsx +13 -2
- package/src/color-picker/index.js +1 -1
- package/src/confirm-dialog/README.md +3 -2
- package/src/confirm-dialog/index.test.js +146 -0
- package/src/confirm-dialog/index.tsx +23 -9
- package/src/dataviews/index.tsx +11 -2
- package/src/divider/index.js +13 -0
- package/src/drawer/README.md +454 -0
- package/src/drawer/action.tsx +67 -0
- package/src/drawer/body.tsx +60 -0
- package/src/drawer/close-icon.tsx +37 -0
- package/src/drawer/content.tsx +54 -0
- package/src/drawer/context.test.js +31 -0
- package/src/drawer/context.ts +25 -0
- package/src/drawer/divider.tsx +13 -0
- package/src/drawer/footer.tsx +15 -0
- package/src/drawer/header.tsx +15 -0
- package/src/drawer/index.test.js +1012 -0
- package/src/drawer/index.tsx +33 -0
- package/src/drawer/root.tsx +328 -0
- package/src/drawer/slots.test.js +314 -0
- package/src/drawer/style.scss +205 -0
- package/src/drawer/title.tsx +40 -0
- package/src/drawer/types.ts +79 -0
- package/src/drawer/use-exit-animation.ts +46 -0
- package/src/hooks/use-confirm-dialog.tsx +14 -3
- package/src/hooks/use-unsaved-changes-dialog.tsx +17 -5
- package/src/index.js +4 -0
- package/src/page/index.js +1 -1
- package/src/page-control/index.js +63 -0
- package/src/plugin-settings/SettingsSection.js +8 -2
- package/src/section-header/index.js +5 -2
- package/src/section-header/style.scss +25 -12
- package/src/table-card/README.md +100 -0
- package/src/table-card/index.test.js +112 -0
- package/src/table-card/index.tsx +56 -0
- package/src/with-wizard-screen/style.scss +8 -0
- package/src/wizard/index.js +95 -34
package/dist/esm/wizard/index.js
CHANGED
|
@@ -10,9 +10,10 @@ import classnames from 'classnames';
|
|
|
10
10
|
/**
|
|
11
11
|
* WordPress dependencies.
|
|
12
12
|
*/
|
|
13
|
-
|
|
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,
|
|
16
|
+
import { cloneElement, createInterpolateElement, isValidElement, useRef, 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
|
|
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,
|
|
@@ -231,10 +270,35 @@ var Wizard = function Wizard(_ref2, ref) {
|
|
|
231
270
|
items: displayedSections,
|
|
232
271
|
children: /*#__PURE__*/_jsx(WizardError, {})
|
|
233
272
|
});
|
|
273
|
+
|
|
274
|
+
// Rendered here rather than as a core admin notice, which wizards strip at
|
|
275
|
+
// priority -9999. Sits as the first child of .newspack-wizard__main so it lands
|
|
276
|
+
// flush beneath the header region and spans the full width in every view: this
|
|
277
|
+
// describes the state of the whole site, not of the section below it, so it reads
|
|
278
|
+
// as page chrome rather than as content.
|
|
279
|
+
var inertGating = (_window$newspack_aux_ = window.newspack_aux_data) === null || _window$newspack_aux_ === void 0 ? void 0 : _window$newspack_aux_.inert_gating;
|
|
280
|
+
var inertGatingNotice = (inertGating === null || inertGating === void 0 ? void 0 : inertGating.show) && /*#__PURE__*/_jsx(CoreNotice, {
|
|
281
|
+
status: "warning",
|
|
282
|
+
isDismissible: false,
|
|
283
|
+
className: "newspack-wizard__inert-gating-notice",
|
|
284
|
+
children: interpolateOrPlainText(inertGating.message, {
|
|
285
|
+
/* eslint-disable jsx-a11y/anchor-has-content */
|
|
286
|
+
accessControl: /*#__PURE__*/_jsx("a", {
|
|
287
|
+
href: inertGating.urls.accessControl
|
|
288
|
+
}),
|
|
289
|
+
audience: /*#__PURE__*/_jsx("a", {
|
|
290
|
+
href: inertGating.urls.audience
|
|
291
|
+
}),
|
|
292
|
+
/* eslint-enable jsx-a11y/anchor-has-content */
|
|
293
|
+
strong: /*#__PURE__*/_jsx("strong", {})
|
|
294
|
+
})
|
|
295
|
+
});
|
|
234
296
|
var content = /*#__PURE__*/_jsxs(_Fragment, {
|
|
235
|
-
children: [/*#__PURE__*/_jsx(HandoffMessage, {}), sections.length > 1 && /*#__PURE__*/_jsx(ResetHeaderData, {}), /*#__PURE__*/
|
|
297
|
+
children: [/*#__PURE__*/_jsx(HandoffMessage, {}), sections.length > 1 && /*#__PURE__*/_jsx(ResetHeaderData, {}), /*#__PURE__*/_jsxs("div", {
|
|
236
298
|
className: "newspack-wizard__main",
|
|
237
|
-
children: /*#__PURE__*/
|
|
299
|
+
children: [inertGatingNotice, /*#__PURE__*/_jsx(WizardBannerSlot, {
|
|
300
|
+
bubblesVirtually: true
|
|
301
|
+
}), /*#__PURE__*/_jsxs(Switch, {
|
|
238
302
|
children: [routedSections.map(function (section, index) {
|
|
239
303
|
var _section$exact;
|
|
240
304
|
var SectionComponent = section.render;
|
|
@@ -265,7 +329,7 @@ var Wizard = function Wizard(_ref2, ref) {
|
|
|
265
329
|
}), /*#__PURE__*/_jsx(Redirect, {
|
|
266
330
|
to: displayedSections[0].path
|
|
267
331
|
})]
|
|
268
|
-
})
|
|
332
|
+
})]
|
|
269
333
|
})]
|
|
270
334
|
});
|
|
271
335
|
var headerActions = (actions === null || actions === void 0 ? void 0 : actions.length) > 0 ? /*#__PURE__*/_jsxs(_Fragment, {
|
|
@@ -316,40 +380,42 @@ var Wizard = function Wizard(_ref2, ref) {
|
|
|
316
380
|
}
|
|
317
381
|
})]
|
|
318
382
|
}) : undefined;
|
|
319
|
-
return /*#__PURE__*/
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
'newspack-wizard__is-loading
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
383
|
+
return /*#__PURE__*/_jsx(SlotFillProvider, {
|
|
384
|
+
children: /*#__PURE__*/_jsxs("div", {
|
|
385
|
+
ref: ref,
|
|
386
|
+
children: [/*#__PURE__*/_jsxs("div", {
|
|
387
|
+
className: classnames(isLoading ? 'newspack-wizard__is-loading' : 'newspack-wizard__is-loaded', {
|
|
388
|
+
'newspack-wizard__is-loading-quiet': isQuietLoading
|
|
389
|
+
}),
|
|
390
|
+
children: [/*#__PURE__*/_jsxs(HashRouter, {
|
|
391
|
+
hashType: "slash",
|
|
392
|
+
children: [newspack_aux_data.is_debug_mode && /*#__PURE__*/_jsx(Notice, {
|
|
393
|
+
debugMode: true
|
|
394
|
+
}), /*#__PURE__*/_jsx(WizardHeaderRegion, {
|
|
395
|
+
hideHeader: hideHeader,
|
|
396
|
+
headerText: headerText,
|
|
397
|
+
sections: routedSections,
|
|
398
|
+
sectionName: sectionName,
|
|
399
|
+
subTitle: subHeaderText,
|
|
400
|
+
actions: headerActions,
|
|
401
|
+
tabbedNavigation: tabbedNavigation,
|
|
402
|
+
children: content
|
|
403
|
+
})]
|
|
404
|
+
}), (notices === null || notices === void 0 ? void 0 : notices.length) > 0 && /*#__PURE__*/_jsx("div", {
|
|
405
|
+
className: "newspack-wizard__snackbar-list",
|
|
406
|
+
children: notices.map(function (notice, index) {
|
|
407
|
+
return /*#__PURE__*/_jsx(WizardSnackbar, {
|
|
408
|
+
id: notice.id,
|
|
409
|
+
type: notice.type,
|
|
410
|
+
actions: notice.actions,
|
|
411
|
+
children: notice.message
|
|
412
|
+
}, notice.id || index);
|
|
413
|
+
})
|
|
338
414
|
})]
|
|
339
|
-
}),
|
|
340
|
-
|
|
341
|
-
children: notices.map(function (notice, index) {
|
|
342
|
-
return /*#__PURE__*/_jsx(WizardSnackbar, {
|
|
343
|
-
id: notice.id,
|
|
344
|
-
type: notice.type,
|
|
345
|
-
actions: notice.actions,
|
|
346
|
-
children: notice.message
|
|
347
|
-
}, notice.id || index);
|
|
348
|
-
})
|
|
415
|
+
}), !isLoading && /*#__PURE__*/_jsx(Footer, {
|
|
416
|
+
simple: hasSimpleFooter
|
|
349
417
|
})]
|
|
350
|
-
})
|
|
351
|
-
simple: hasSimpleFooter
|
|
352
|
-
})]
|
|
418
|
+
})
|
|
353
419
|
});
|
|
354
420
|
};
|
|
355
421
|
export default forwardRef(Wizard);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "newspack-components",
|
|
3
|
-
"version": "4.
|
|
3
|
+
"version": "4.7.0-alpha.2",
|
|
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:
|
|
38
|
-
|
|
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
|
+
};
|
package/src/breadcrumbs/index.js
CHANGED
|
@@ -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
|
-
|
|
39
|
-
|
|
40
|
-
{ item.
|
|
41
|
-
|
|
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">
|
|
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
|
|
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
|
+
} );
|
package/src/button/index.tsx
CHANGED
|
@@ -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 (
|
|
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
|
-
|
|
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 =
|
|
64
|
+
( otherProps as Props ).href = safeHref;
|
|
40
65
|
( otherProps as Props ).onClick = onClick;
|
|
41
66
|
}
|
|
42
67
|
if ( isAwaitingOnClick ) {
|