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.
- package/CHANGELOG.md +23 -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/src/card/core-card.js
CHANGED
|
@@ -41,7 +41,16 @@ const CoreCard = ( {
|
|
|
41
41
|
isFirstTarget,
|
|
42
42
|
isLastTarget,
|
|
43
43
|
isNarrow,
|
|
44
|
+
/**
|
|
45
|
+
* Renders the card as a chooser: strips the button chrome an `as="button"` card
|
|
46
|
+
* inherits, and adds the hover and focus rings. Pair with `isActive` for the
|
|
47
|
+
* chosen one. Unlike `buttonsCard` this leaves `as` alone, so the consumer keeps
|
|
48
|
+
* the element and the ARIA it needs (`radio`, `option`, …).
|
|
49
|
+
*/
|
|
50
|
+
isSelectable,
|
|
44
51
|
isSmall,
|
|
52
|
+
isVertical,
|
|
53
|
+
size,
|
|
45
54
|
dragIndex,
|
|
46
55
|
onDragCallback = () => {},
|
|
47
56
|
onToggle = () => {},
|
|
@@ -53,6 +62,7 @@ const CoreCard = ( {
|
|
|
53
62
|
hasHeaderBorder = true,
|
|
54
63
|
...otherProps
|
|
55
64
|
} ) => {
|
|
65
|
+
const bodySize = isSmall ? undefined : size;
|
|
56
66
|
const classes = classNames(
|
|
57
67
|
'newspack-card--core',
|
|
58
68
|
className,
|
|
@@ -61,6 +71,9 @@ const CoreCard = ( {
|
|
|
61
71
|
isDraggable && 'newspack-card--core__is-draggable',
|
|
62
72
|
isNarrow && 'newspack-card--core__is-narrow',
|
|
63
73
|
isSmall && 'newspack-card--core__is-small',
|
|
74
|
+
isSelectable && 'newspack-card--core__is-selectable',
|
|
75
|
+
bodySize === 'large' && 'newspack-card--core__is-large',
|
|
76
|
+
isVertical && 'newspack-card--core__is-vertical',
|
|
64
77
|
( icon || iconElement ) && 'newspack-card--core__has-icon',
|
|
65
78
|
iconBackgroundColor && 'newspack-card--core__has-icon-background-color',
|
|
66
79
|
isActive && 'newspack-card--core__is-active',
|
|
@@ -69,7 +82,7 @@ const CoreCard = ( {
|
|
|
69
82
|
noMargin && 'newspack-card--core__no-margin',
|
|
70
83
|
hasGreyHeader && 'newspack-card--core__has-grey-header'
|
|
71
84
|
);
|
|
72
|
-
let sizeProps = isSmall ? 'small' :
|
|
85
|
+
let sizeProps = isSmall ? 'small' : size;
|
|
73
86
|
if ( buttonsCard || as === 'a' ) {
|
|
74
87
|
if ( ! isSmall ) {
|
|
75
88
|
sizeProps = 'large';
|
|
@@ -87,7 +100,7 @@ const CoreCard = ( {
|
|
|
87
100
|
const hasInteractiveHeaderChildren = actionType === 'toggle' || !! headerAction || actions?.length > 0 || isDraggable;
|
|
88
101
|
const headerIsButton = !! onHeaderClick && ! hasInteractiveHeaderChildren;
|
|
89
102
|
return (
|
|
90
|
-
<CardWrapper as={ as } className={ classes } { ...otherProps }>
|
|
103
|
+
<CardWrapper as={ as } className={ classes } size={ bodySize } { ...otherProps }>
|
|
91
104
|
{ ( header || icon || iconElement ) && (
|
|
92
105
|
<CardHeader
|
|
93
106
|
as={ headerIsButton ? 'button' : undefined }
|
|
@@ -39,4 +39,23 @@ describe( 'CoreCard', () => {
|
|
|
39
39
|
const { container } = render( <CoreCard header="Settings" isDraggable onHeaderClick={ () => {} } /> );
|
|
40
40
|
expect( getHeader( container ).tagName ).not.toBe( 'BUTTON' );
|
|
41
41
|
} );
|
|
42
|
+
|
|
43
|
+
it( 'gives the body large padding when size is large', () => {
|
|
44
|
+
const { container } = render( <CoreCard size="large" /> );
|
|
45
|
+
expect( container.querySelector( '.newspack-card--core__is-large' ) ).not.toBeNull();
|
|
46
|
+
} );
|
|
47
|
+
|
|
48
|
+
it( 'lets isSmall win over a large size', () => {
|
|
49
|
+
const { container } = render( <CoreCard size="large" isSmall /> );
|
|
50
|
+
expect( container.querySelector( '.newspack-card--core__is-large' ) ).toBeNull();
|
|
51
|
+
expect( container.querySelector( '.newspack-card--core__is-small' ) ).not.toBeNull();
|
|
52
|
+
} );
|
|
53
|
+
|
|
54
|
+
it( 'marks the card vertical only when isVertical is passed', () => {
|
|
55
|
+
const { container: plain } = render( <CoreCard header="Settings" /> );
|
|
56
|
+
expect( plain.querySelector( '.newspack-card--core__is-vertical' ) ).toBeNull();
|
|
57
|
+
|
|
58
|
+
const { container: vertical } = render( <CoreCard header="Settings" isVertical /> );
|
|
59
|
+
expect( vertical.querySelector( '.newspack-card--core__is-vertical' ) ).not.toBeNull();
|
|
60
|
+
} );
|
|
42
61
|
} );
|
package/src/card/index.js
CHANGED
|
@@ -32,6 +32,8 @@ class Card extends Component {
|
|
|
32
32
|
isSmall,
|
|
33
33
|
isWhite,
|
|
34
34
|
noBorder,
|
|
35
|
+
// Only honoured on the core-card branch below.
|
|
36
|
+
size,
|
|
35
37
|
// Pass as `true` to render using WP Core's Card component: https://wordpress.github.io/gutenberg/?path=/docs/components-card--docs
|
|
36
38
|
__experimentalCoreCard,
|
|
37
39
|
// Pass props supported by WP Core's Card component in this single prop.
|
|
@@ -57,6 +59,7 @@ class Card extends Component {
|
|
|
57
59
|
isSmall,
|
|
58
60
|
isWhite,
|
|
59
61
|
noBorder,
|
|
62
|
+
size,
|
|
60
63
|
...otherProps,
|
|
61
64
|
...__experimentalCoreProps,
|
|
62
65
|
};
|
package/src/card/style-core.scss
CHANGED
|
@@ -59,6 +59,19 @@
|
|
|
59
59
|
}
|
|
60
60
|
}
|
|
61
61
|
}
|
|
62
|
+
&__is-vertical {
|
|
63
|
+
.newspack-card--core__header {
|
|
64
|
+
align-items: flex-start;
|
|
65
|
+
flex-direction: column;
|
|
66
|
+
height: 100%;
|
|
67
|
+
justify-content: flex-start;
|
|
68
|
+
}
|
|
69
|
+
.newspack-card--core__header-content {
|
|
70
|
+
display: flex;
|
|
71
|
+
flex-direction: column;
|
|
72
|
+
gap: wp-vars.$grid-unit-10;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
62
75
|
&__no-margin .components-card__body {
|
|
63
76
|
padding: 0 !important;
|
|
64
77
|
}
|
|
@@ -202,6 +215,48 @@
|
|
|
202
215
|
}
|
|
203
216
|
}
|
|
204
217
|
}
|
|
218
|
+
&__is-selectable {
|
|
219
|
+
appearance: none;
|
|
220
|
+
border: 0;
|
|
221
|
+
color: inherit;
|
|
222
|
+
cursor: pointer;
|
|
223
|
+
font: inherit;
|
|
224
|
+
margin: 0;
|
|
225
|
+
padding: 0;
|
|
226
|
+
text-align: inherit;
|
|
227
|
+
|
|
228
|
+
&:hover {
|
|
229
|
+
box-shadow: var(--wp-admin-theme-color) 0 0 0 1px;
|
|
230
|
+
|
|
231
|
+
.newspack-card--core__header,
|
|
232
|
+
.newspack-card--core__header h1,
|
|
233
|
+
.newspack-card--core__header h2,
|
|
234
|
+
.newspack-card--core__header h3,
|
|
235
|
+
.newspack-card--core__header h4,
|
|
236
|
+
.newspack-card--core__header h5,
|
|
237
|
+
.newspack-card--core__header h6 {
|
|
238
|
+
color: var(--wp-admin-theme-color);
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
&:focus-visible {
|
|
243
|
+
box-shadow: inset 0 0 0 2px var(--wp-admin-theme-color);
|
|
244
|
+
outline: 2px solid transparent;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
@media ( prefers-contrast: more ) {
|
|
248
|
+
&.newspack-card--core__is-active {
|
|
249
|
+
box-shadow: var(--wp-admin-theme-color) 0 0 0 2px;
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
@media ( forced-colors: active ) {
|
|
254
|
+
&.newspack-card--core__is-active {
|
|
255
|
+
outline: 3px solid CanvasText;
|
|
256
|
+
outline-offset: -3px;
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
}
|
|
205
260
|
&__is-active:not(.newspack-card--core__is-draggable) {
|
|
206
261
|
border-color: var(--wp-admin-theme-color);
|
|
207
262
|
box-shadow: var(--wp-admin-theme-color) 0 0 0 1px;
|
|
@@ -298,6 +353,10 @@
|
|
|
298
353
|
.newspack-card--core__is-small & {
|
|
299
354
|
padding: 16px;
|
|
300
355
|
}
|
|
356
|
+
|
|
357
|
+
.newspack-card--core__is-large & {
|
|
358
|
+
padding: wp-vars.$grid-unit-60;
|
|
359
|
+
}
|
|
301
360
|
.newspack-card--core__no-margin & {
|
|
302
361
|
padding: 0;
|
|
303
362
|
}
|
|
@@ -7,6 +7,10 @@ A card component for presenting a named feature or setting with a predictable, s
|
|
|
7
7
|
- **Maximum 2 cards per row.** Use `<Grid columns={ 2 }>` — never 3 or more. Cards are designed to be read, not scanned, and 3+ columns makes them too narrow for the content.
|
|
8
8
|
- The icon is always displayed on the **right-hand side**, aligned to the top of the content.
|
|
9
9
|
|
|
10
|
+
## Accessibility
|
|
11
|
+
|
|
12
|
+
Cards sit side by side, so a bare "Enable" names no feature out of context. The card appends `title` to the primary button's accessible name ("Enable Metered Countdown") and to the "More" menu ("More options for Metered Countdown"). The visible label leads, so voice control still matches on the word the user can see. Nothing to pass — this is automatic.
|
|
13
|
+
|
|
10
14
|
## States
|
|
11
15
|
|
|
12
16
|
| State | Condition | Button | Dropdown | Badge |
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* External dependencies.
|
|
3
|
+
*/
|
|
4
|
+
import { render, screen } from '@testing-library/react';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* WordPress dependencies.
|
|
8
|
+
*/
|
|
9
|
+
import { _x } from '@wordpress/i18n';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Internal dependencies.
|
|
13
|
+
*/
|
|
14
|
+
import CardFeature from './index';
|
|
15
|
+
|
|
16
|
+
// The spy lives in the factory: @wordpress/components calls _x while it loads,
|
|
17
|
+
// before a module-scope const would be initialized.
|
|
18
|
+
jest.mock( '@wordpress/i18n', () => {
|
|
19
|
+
const actual = jest.requireActual( '@wordpress/i18n' );
|
|
20
|
+
return { ...actual, _x: jest.fn( text => text ) };
|
|
21
|
+
} );
|
|
22
|
+
|
|
23
|
+
describe( 'CardFeature', () => {
|
|
24
|
+
beforeEach( () => {
|
|
25
|
+
_x.mockClear();
|
|
26
|
+
} );
|
|
27
|
+
|
|
28
|
+
it( 'names the feature in the primary button, keeping the visible label first', () => {
|
|
29
|
+
render( <CardFeature title="Metered Countdown" /> );
|
|
30
|
+
const button = screen.getByRole( 'button', { name: 'Enable Metered Countdown' } );
|
|
31
|
+
expect( button ).toHaveTextContent( 'Enable' );
|
|
32
|
+
expect( button.getAttribute( 'aria-label' ).startsWith( button.textContent ) ).toBe( true );
|
|
33
|
+
} );
|
|
34
|
+
|
|
35
|
+
it( 'carries a custom label into the accessible name', () => {
|
|
36
|
+
render( <CardFeature title="Subscription retention" enableLabel="Change" /> );
|
|
37
|
+
expect( screen.getByRole( 'button', { name: 'Change Subscription retention' } ) ).toHaveTextContent( 'Change' );
|
|
38
|
+
} );
|
|
39
|
+
|
|
40
|
+
it( 'names the feature in the configure state too', () => {
|
|
41
|
+
render( <CardFeature title="Content Gifting" enabled /> );
|
|
42
|
+
expect( screen.getByRole( 'button', { name: 'Configure Content Gifting' } ) ).toBeInTheDocument();
|
|
43
|
+
} );
|
|
44
|
+
|
|
45
|
+
it( 'names the feature in the More menu', () => {
|
|
46
|
+
render( <CardFeature title="Content Gifting" enabled moreControls={ [ { title: 'Disable', onClick: () => {} } ] } /> );
|
|
47
|
+
expect( screen.getByRole( 'button', { name: 'More options for Content Gifting' } ) ).toBeInTheDocument();
|
|
48
|
+
} );
|
|
49
|
+
|
|
50
|
+
it( 'gives the accessible-name order its own catalogue entry', () => {
|
|
51
|
+
render( <CardFeature title="Metered Countdown" /> );
|
|
52
|
+
expect( _x ).toHaveBeenCalledWith( '%1$s %2$s', 'accessible button name: visible action label, then feature name', 'newspack-plugin' );
|
|
53
|
+
} );
|
|
54
|
+
|
|
55
|
+
it( 'distinguishes two cards that share a button label', () => {
|
|
56
|
+
render(
|
|
57
|
+
<>
|
|
58
|
+
<CardFeature title="Metered Countdown" />
|
|
59
|
+
<CardFeature title="Content Gifting" />
|
|
60
|
+
</>
|
|
61
|
+
);
|
|
62
|
+
expect( screen.getByRole( 'button', { name: 'Enable Metered Countdown' } ) ).toBeInTheDocument();
|
|
63
|
+
expect( screen.getByRole( 'button', { name: 'Enable Content Gifting' } ) ).toBeInTheDocument();
|
|
64
|
+
} );
|
|
65
|
+
} );
|
|
@@ -6,7 +6,7 @@ import classnames from 'classnames';
|
|
|
6
6
|
/**
|
|
7
7
|
* WordPress dependencies
|
|
8
8
|
*/
|
|
9
|
-
import { __ } from '@wordpress/i18n';
|
|
9
|
+
import { __, _x, sprintf } from '@wordpress/i18n';
|
|
10
10
|
import { isValidElement } from '@wordpress/element';
|
|
11
11
|
import { DropdownMenu, __experimentalHStack as HStack } from '@wordpress/components'; // eslint-disable-line @wordpress/no-unsafe-wp-apis
|
|
12
12
|
import { moreVertical } from '@wordpress/icons';
|
|
@@ -120,6 +120,12 @@ const CardFeature = ( {
|
|
|
120
120
|
|
|
121
121
|
const isConfigureState = enabled && ! requirements;
|
|
122
122
|
const buttonLabel = isConfigureState ? configureLabel ?? __( 'Configure', 'newspack-plugin' ) : enableLabel ?? __( 'Enable', 'newspack-plugin' );
|
|
123
|
+
const buttonAccessibleLabel = sprintf(
|
|
124
|
+
// translators: %1$s: the button's visible action label, e.g. "Enable". %2$s: the feature's name. The visible label must stay first (WCAG 2.5.3).
|
|
125
|
+
_x( '%1$s %2$s', 'accessible button name: visible action label, then feature name', 'newspack-plugin' ),
|
|
126
|
+
buttonLabel,
|
|
127
|
+
title
|
|
128
|
+
);
|
|
123
129
|
const showMoreControls = enabled && !! moreControls?.length && ( ! requirements || requirementsActionable );
|
|
124
130
|
|
|
125
131
|
const handleButtonClick = () => {
|
|
@@ -178,13 +184,18 @@ const CardFeature = ( {
|
|
|
178
184
|
isBusy={ busy }
|
|
179
185
|
onClick={ handleButtonClick }
|
|
180
186
|
size="compact"
|
|
187
|
+
aria-label={ buttonAccessibleLabel }
|
|
181
188
|
>
|
|
182
189
|
{ buttonLabel }
|
|
183
190
|
</Button>
|
|
184
191
|
{ showMoreControls && (
|
|
185
192
|
<DropdownMenu
|
|
186
193
|
icon={ moreVertical }
|
|
187
|
-
label={
|
|
194
|
+
label={ sprintf(
|
|
195
|
+
// translators: %s: the feature's name.
|
|
196
|
+
__( 'More options for %s', 'newspack-plugin' ),
|
|
197
|
+
title
|
|
198
|
+
) }
|
|
188
199
|
controls={ moreControls }
|
|
189
200
|
toggleProps={ { size: 'compact' } }
|
|
190
201
|
/>
|
|
@@ -27,7 +27,7 @@ const { InteractiveDiv } = utils;
|
|
|
27
27
|
*
|
|
28
28
|
* @param {Object} props - Component props.
|
|
29
29
|
* @param {JSX.Element|string} props.label - Label for the color picker.
|
|
30
|
-
* @param {JSX.Element|string} props.help
|
|
30
|
+
* @param {JSX.Element|string} [props.help] - Help text for the color picker.
|
|
31
31
|
* @param {string} [props.color] - Default color.
|
|
32
32
|
* @param {Function} props.onChange - Function to call when the color changes.
|
|
33
33
|
* @param {string} [props.className] - Additional class name.
|
|
@@ -54,7 +54,7 @@ const [ pendingAction, setPendingAction ] = useState( null );
|
|
|
54
54
|
<ConfirmDialog
|
|
55
55
|
when={ hasUnsavedChanges }
|
|
56
56
|
isOpen={ !! pendingAction }
|
|
57
|
-
confirmButtonText="Discard
|
|
57
|
+
confirmButtonText="Discard Changes"
|
|
58
58
|
isDestructive
|
|
59
59
|
hideTitle
|
|
60
60
|
onConfirm={ () => {
|
|
@@ -92,6 +92,7 @@ Accepts all `ConfirmDialog` props except `isOpen`, `onConfirm`, `onCancel`, and
|
|
|
92
92
|
|-----|------|-------------|
|
|
93
93
|
| `confirmDialog` | `React.ReactElement` | The dialog element — render this somewhere in your JSX. |
|
|
94
94
|
| `requestConfirm` | `( callback: () => void ) => void` | Call with a callback to request confirmation. Shows the dialog (unless `when` is `false`); invokes the callback only if the user confirms. |
|
|
95
|
+
| `cancelConfirm` | `() => void` | Withdraws a pending request without invoking its callback. Call it when whatever raised the prompt goes away on its own, so the dialog does not outlive it. |
|
|
95
96
|
|
|
96
97
|
### Usage
|
|
97
98
|
|
|
@@ -115,7 +116,7 @@ const { confirmDialog, requestConfirm } = useConfirmDialog( {
|
|
|
115
116
|
const { confirmDialog, requestConfirm } = useConfirmDialog( {
|
|
116
117
|
when: isDirty,
|
|
117
118
|
message: 'You have unsaved changes that will be lost. Discard changes?',
|
|
118
|
-
confirmButtonText: 'Discard
|
|
119
|
+
confirmButtonText: 'Discard Changes',
|
|
119
120
|
isDestructive: true,
|
|
120
121
|
hideTitle: true,
|
|
121
122
|
} );
|
|
@@ -48,6 +48,29 @@ describe( 'ConfirmDialog navigation blocking', () => {
|
|
|
48
48
|
expect( history.location.pathname ).toBe( '/' );
|
|
49
49
|
} );
|
|
50
50
|
|
|
51
|
+
// v5 keeps a single prompt slot, so an unprompted re-sync would be caught by
|
|
52
|
+
// whichever dialog on the screen did install a blocker.
|
|
53
|
+
it( 'cancels an unprompted dialog without tripping another blocker', () => {
|
|
54
|
+
const historyRef = { current: null };
|
|
55
|
+
render(
|
|
56
|
+
<MemoryRouter>
|
|
57
|
+
<HistoryGrabber historyRef={ historyRef } />
|
|
58
|
+
<ConfirmDialog when confirmButtonText="Leave" cancelButtonText="Stay">
|
|
59
|
+
Wizard guard
|
|
60
|
+
</ConfirmDialog>
|
|
61
|
+
<ConfirmDialog isOpen confirmButtonText="Discard changes" cancelButtonText="Keep editing">
|
|
62
|
+
Unsaved changes
|
|
63
|
+
</ConfirmDialog>
|
|
64
|
+
</MemoryRouter>
|
|
65
|
+
);
|
|
66
|
+
const replaceSpy = jest.spyOn( historyRef.current, 'replace' );
|
|
67
|
+
|
|
68
|
+
cancelNavigation();
|
|
69
|
+
expect( dialog() ).not.toBeInTheDocument();
|
|
70
|
+
expect( screen.queryByText( 'Wizard guard' ) ).not.toBeInTheDocument();
|
|
71
|
+
expect( replaceSpy ).not.toHaveBeenCalled();
|
|
72
|
+
} );
|
|
73
|
+
|
|
51
74
|
it( 'replays a blocked push on confirm: re-sync replace first, then push, and re-arms the blocker', () => {
|
|
52
75
|
const history = renderWithHistory();
|
|
53
76
|
const replaceSpy = jest.spyOn( history, 'replace' );
|
|
@@ -140,3 +163,126 @@ describe( 'ConfirmDialog navigation blocking', () => {
|
|
|
140
163
|
expect( dialog() ).toBeInTheDocument();
|
|
141
164
|
} );
|
|
142
165
|
} );
|
|
166
|
+
|
|
167
|
+
// `useHistory` returns undefined outside a wizard, where `newspack-multibranded-site`
|
|
168
|
+
// runs react-router v6 against this package's v5.
|
|
169
|
+
describe( 'ConfirmDialog without a router', () => {
|
|
170
|
+
const renderUnrouted = ( props = {} ) =>
|
|
171
|
+
render(
|
|
172
|
+
<ConfirmDialog isOpen confirmButtonText="Discard changes" cancelButtonText="Keep editing" { ...props }>
|
|
173
|
+
Unsaved changes
|
|
174
|
+
</ConfirmDialog>
|
|
175
|
+
);
|
|
176
|
+
|
|
177
|
+
it( 'cancels with no history to re-sync', () => {
|
|
178
|
+
const onCancel = jest.fn();
|
|
179
|
+
renderUnrouted( { onCancel } );
|
|
180
|
+
expect( dialog() ).toBeInTheDocument();
|
|
181
|
+
|
|
182
|
+
cancelNavigation();
|
|
183
|
+
expect( onCancel ).toHaveBeenCalled();
|
|
184
|
+
expect( dialog() ).not.toBeInTheDocument();
|
|
185
|
+
} );
|
|
186
|
+
|
|
187
|
+
it( 'confirms with no history to re-sync', () => {
|
|
188
|
+
const onConfirm = jest.fn();
|
|
189
|
+
renderUnrouted( { onConfirm } );
|
|
190
|
+
|
|
191
|
+
confirmNavigation();
|
|
192
|
+
expect( onConfirm ).toHaveBeenCalled();
|
|
193
|
+
expect( dialog() ).not.toBeInTheDocument();
|
|
194
|
+
} );
|
|
195
|
+
|
|
196
|
+
it( 'installs no blocker when asked to block without a router', () => {
|
|
197
|
+
expect( () => renderUnrouted( { when: true } ) ).not.toThrow();
|
|
198
|
+
expect( dialog() ).toBeInTheDocument();
|
|
199
|
+
} );
|
|
200
|
+
} );
|
|
201
|
+
|
|
202
|
+
describe( 'ConfirmDialog controlled by isOpen', () => {
|
|
203
|
+
const tree = isOpen => (
|
|
204
|
+
<ConfirmDialog isOpen={ isOpen } confirmButtonText="Discard changes" cancelButtonText="Keep editing">
|
|
205
|
+
Unsaved changes
|
|
206
|
+
</ConfirmDialog>
|
|
207
|
+
);
|
|
208
|
+
|
|
209
|
+
it( 'closes when isOpen goes false', () => {
|
|
210
|
+
const { rerender } = render( tree( true ) );
|
|
211
|
+
expect( dialog() ).toBeInTheDocument();
|
|
212
|
+
|
|
213
|
+
rerender( tree( false ) );
|
|
214
|
+
expect( dialog() ).not.toBeInTheDocument();
|
|
215
|
+
} );
|
|
216
|
+
|
|
217
|
+
it( 'opens again afterwards', () => {
|
|
218
|
+
const { rerender } = render( tree( true ) );
|
|
219
|
+
rerender( tree( false ) );
|
|
220
|
+
|
|
221
|
+
rerender( tree( true ) );
|
|
222
|
+
expect( dialog() ).toBeInTheDocument();
|
|
223
|
+
} );
|
|
224
|
+
|
|
225
|
+
it( 'leaves a blocked navigation prompt up', () => {
|
|
226
|
+
const historyRef = { current: null };
|
|
227
|
+
render(
|
|
228
|
+
<MemoryRouter>
|
|
229
|
+
<HistoryGrabber historyRef={ historyRef } />
|
|
230
|
+
<ConfirmDialog when isOpen={ false } confirmButtonText="Discard changes" cancelButtonText="Keep editing">
|
|
231
|
+
Unsaved changes
|
|
232
|
+
</ConfirmDialog>
|
|
233
|
+
</MemoryRouter>
|
|
234
|
+
);
|
|
235
|
+
|
|
236
|
+
act( () => historyRef.current.push( '/next' ) );
|
|
237
|
+
expect( dialog() ).toBeInTheDocument();
|
|
238
|
+
} );
|
|
239
|
+
|
|
240
|
+
it( 'withdraws a blocked navigation prompt when isOpen goes false', () => {
|
|
241
|
+
const historyRef = { current: null };
|
|
242
|
+
const withOpen = isOpen => (
|
|
243
|
+
<MemoryRouter>
|
|
244
|
+
<HistoryGrabber historyRef={ historyRef } />
|
|
245
|
+
<ConfirmDialog when isOpen={ isOpen } confirmButtonText="Discard changes" cancelButtonText="Keep editing">
|
|
246
|
+
Unsaved changes
|
|
247
|
+
</ConfirmDialog>
|
|
248
|
+
</MemoryRouter>
|
|
249
|
+
);
|
|
250
|
+
const { rerender } = render( withOpen( true ) );
|
|
251
|
+
act( () => historyRef.current.push( '/next' ) );
|
|
252
|
+
expect( dialog() ).toBeInTheDocument();
|
|
253
|
+
|
|
254
|
+
rerender( withOpen( false ) );
|
|
255
|
+
expect( dialog() ).not.toBeInTheDocument();
|
|
256
|
+
expect( historyRef.current.location.pathname ).toBe( '/' );
|
|
257
|
+
|
|
258
|
+
// Confirming the next prompt must not replay the abandoned destination.
|
|
259
|
+
rerender( withOpen( true ) );
|
|
260
|
+
confirmNavigation();
|
|
261
|
+
expect( historyRef.current.location.pathname ).toBe( '/' );
|
|
262
|
+
} );
|
|
263
|
+
} );
|
|
264
|
+
|
|
265
|
+
describe( 'ConfirmDialog named by a hidden title', () => {
|
|
266
|
+
it( 'takes its accessible name from the title while hiding the header', () => {
|
|
267
|
+
render(
|
|
268
|
+
<ConfirmDialog isOpen hideTitle title="Unsaved changes" confirmButtonText="Discard changes">
|
|
269
|
+
You have unsaved changes that will be lost. Discard changes?
|
|
270
|
+
</ConfirmDialog>
|
|
271
|
+
);
|
|
272
|
+
|
|
273
|
+
const frame = screen.getByRole( 'dialog', { name: 'Unsaved changes' } );
|
|
274
|
+
expect( frame ).toHaveClass( 'newspack-modal--hide-title' );
|
|
275
|
+
expect( frame.querySelector( '.components-modal__header' ) ).toContainElement( screen.getByRole( 'heading', { name: 'Unsaved changes' } ) );
|
|
276
|
+
} );
|
|
277
|
+
|
|
278
|
+
it( 'has no accessible name without a title', () => {
|
|
279
|
+
render(
|
|
280
|
+
<ConfirmDialog isOpen hideTitle confirmButtonText="Discard changes">
|
|
281
|
+
You have unsaved changes that will be lost. Discard changes?
|
|
282
|
+
</ConfirmDialog>
|
|
283
|
+
);
|
|
284
|
+
|
|
285
|
+
expect( screen.queryByRole( 'dialog', { name: 'Unsaved changes' } ) ).not.toBeInTheDocument();
|
|
286
|
+
expect( screen.getByRole( 'dialog' ) ).not.toHaveAttribute( 'aria-labelledby' );
|
|
287
|
+
} );
|
|
288
|
+
} );
|
|
@@ -79,21 +79,26 @@ function ConfirmDialog(
|
|
|
79
79
|
|
|
80
80
|
const handleOnCancel = useCallback( () => {
|
|
81
81
|
setShowDialog( false );
|
|
82
|
+
const hadPendingNavigation = !! pendingNavigation.current;
|
|
82
83
|
pendingNavigation.current = null;
|
|
83
84
|
// A POP may have moved the URL to the target before it was blocked; put
|
|
84
85
|
// it back so the address bar matches the page the user chose to stay on.
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
86
|
+
// Only when this instance blocked it: v5 keeps one prompt slot, so an
|
|
87
|
+
// unprompted replace is caught by whichever dialog did install a blocker.
|
|
88
|
+
if ( hadPendingNavigation && history ) {
|
|
89
|
+
bypassBlock.current = true;
|
|
90
|
+
try {
|
|
91
|
+
history.replace( history.location );
|
|
92
|
+
} finally {
|
|
93
|
+
bypassBlock.current = false;
|
|
94
|
+
}
|
|
90
95
|
}
|
|
91
96
|
onCancel();
|
|
92
97
|
}, [ onCancel, history ] );
|
|
93
98
|
|
|
94
99
|
// Block navigation when there are unsaved changes.
|
|
95
100
|
useEffect( () => {
|
|
96
|
-
if ( ! when ) {
|
|
101
|
+
if ( ! when || ! history ) {
|
|
97
102
|
return;
|
|
98
103
|
}
|
|
99
104
|
const unblock = history.block( ( location: string, action: string ) => {
|
|
@@ -124,10 +129,19 @@ function ConfirmDialog(
|
|
|
124
129
|
return unblock;
|
|
125
130
|
}, [ when, history ] );
|
|
126
131
|
|
|
127
|
-
//
|
|
132
|
+
// During render: a commit later the dialog would still hold focus while the
|
|
133
|
+
// caller unmounted. The blocker above raises it without touching `isOpen`.
|
|
134
|
+
const [ wasOpen, setWasOpen ] = useState( isOpen );
|
|
135
|
+
if ( wasOpen !== isOpen ) {
|
|
136
|
+
setWasOpen( isOpen );
|
|
137
|
+
setShowDialog( isOpen );
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// A withdrawn prompt releases what the blocker held, as confirm and cancel do.
|
|
141
|
+
// After the commit, so a discarded render cannot drop a live one.
|
|
128
142
|
useEffect( () => {
|
|
129
|
-
if ( isOpen ) {
|
|
130
|
-
|
|
143
|
+
if ( ! isOpen ) {
|
|
144
|
+
pendingNavigation.current = null;
|
|
131
145
|
}
|
|
132
146
|
}, [ isOpen ] );
|
|
133
147
|
|
package/src/dataviews/index.tsx
CHANGED
|
@@ -19,11 +19,11 @@ import classnames from 'classnames';
|
|
|
19
19
|
*/
|
|
20
20
|
import './style.scss';
|
|
21
21
|
|
|
22
|
-
type DataViewsProps = React.ComponentProps< typeof BaseDataViews > & {
|
|
22
|
+
type DataViewsProps< Item > = React.ComponentProps< typeof BaseDataViews< Item > > & {
|
|
23
23
|
className?: string;
|
|
24
24
|
};
|
|
25
25
|
|
|
26
|
-
|
|
26
|
+
function DataViews( { className, ...props }: DataViewsProps< unknown > ) {
|
|
27
27
|
return (
|
|
28
28
|
<div className={ classnames( 'newspack-dataviews', className ) }>
|
|
29
29
|
<BaseDataViews { ...props } />
|
|
@@ -31,4 +31,13 @@ export default function DataViews( { className, ...props }: DataViewsProps ) {
|
|
|
31
31
|
);
|
|
32
32
|
}
|
|
33
33
|
|
|
34
|
+
// The implementation is typed against the `unknown` instantiation because
|
|
35
|
+
// BaseDataViews' conditionally-optional `getItemId` prop cannot be related
|
|
36
|
+
// to a JSX spread while `Item` is an unresolved type parameter. The export
|
|
37
|
+
// re-types it with the generic signature so call sites get item-aware
|
|
38
|
+
// typing for `data`, `fields`, `actions`, `getItemId`, etc. The assertion
|
|
39
|
+
// is verified by the compiler: the generic signature is assignable to the
|
|
40
|
+
// implementation's concrete one.
|
|
41
|
+
export default DataViews as < Item >( props: DataViewsProps< Item > ) => React.JSX.Element;
|
|
42
|
+
|
|
34
43
|
export type { Action, Field, View } from '@wordpress/dataviews';
|
package/src/divider/index.js
CHANGED
|
@@ -12,6 +12,19 @@ import './style.scss';
|
|
|
12
12
|
*/
|
|
13
13
|
import classNames from 'classnames';
|
|
14
14
|
|
|
15
|
+
/**
|
|
16
|
+
* Divider component.
|
|
17
|
+
*
|
|
18
|
+
* Every prop beyond the documented ones is forwarded to the `hr` element.
|
|
19
|
+
*
|
|
20
|
+
* @param {import('react').ComponentPropsWithoutRef<'hr'> & {
|
|
21
|
+
* alignment?: string,
|
|
22
|
+
* marginBottom?: number|string,
|
|
23
|
+
* marginTop?: number|string,
|
|
24
|
+
* variant?: string,
|
|
25
|
+
* }} props - Component props: the documented options plus any `hr` attribute.
|
|
26
|
+
* @return {JSX.Element} Divider component.
|
|
27
|
+
*/
|
|
15
28
|
const Divider = ( { alignment = 'none', className = undefined, marginBottom = 64, marginTop = 64, variant = 'default', ...otherProps } ) => {
|
|
16
29
|
const classes = classNames(
|
|
17
30
|
'newspack-divider',
|