newspack-components 4.5.0 → 4.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +40 -0
- package/DEVELOPMENT.md +1 -1
- package/dist/cjs/accordion/index.js +43 -26
- package/dist/cjs/accordion/index.test.js +80 -0
- package/dist/cjs/accordion/style.scss +25 -21
- package/dist/cjs/action-card/style.scss +0 -22
- package/dist/cjs/breadcrumbs/index.js +80 -0
- package/dist/cjs/breadcrumbs/index.test.js +109 -0
- package/dist/cjs/breadcrumbs/style.scss +41 -0
- package/dist/cjs/card/core-card.js +8 -4
- package/dist/cjs/card/index.js +1 -1
- package/dist/cjs/card/style-core.scss +14 -0
- package/dist/cjs/card/style.scss +0 -9
- package/dist/cjs/card-feature/index.js +25 -13
- package/dist/cjs/card-settings-group/index.js +3 -0
- package/dist/cjs/confirm-dialog/index.js +34 -8
- package/dist/cjs/confirm-dialog/index.test.js +173 -0
- package/dist/cjs/divider/style.scss +4 -2
- package/dist/cjs/grid/style.scss +5 -0
- package/dist/cjs/handoff/index.js +8 -5
- package/dist/cjs/index.js +34 -1
- package/dist/cjs/integration-icon/index.js +40 -0
- package/dist/cjs/integration-icon/style.scss +42 -0
- package/dist/cjs/integration-icons/index.js +21 -2
- package/dist/cjs/modal/style.scss +4 -0
- package/dist/cjs/page/index.js +121 -0
- package/dist/cjs/page/index.test.js +69 -0
- package/dist/cjs/page/style.scss +86 -0
- package/dist/cjs/route-match.js +59 -0
- package/dist/cjs/route-match.test.js +77 -0
- package/dist/cjs/section-header/index.js +8 -1
- package/dist/cjs/section-header/style.scss +11 -11
- package/dist/cjs/select-control/GroupedSelectControl.js +1 -1
- package/dist/cjs/select-control/style.scss +4 -0
- package/dist/cjs/style.scss +1 -4
- package/dist/cjs/tabbed-navigation/index.js +188 -40
- package/dist/cjs/tabbed-navigation/index.test.js +299 -0
- package/dist/cjs/tabbed-navigation/style.scss +77 -57
- package/dist/cjs/with-wizard/index.js +34 -45
- package/dist/cjs/with-wizard-screen/index.js +82 -41
- package/dist/cjs/with-wizard-screen/index.test.js +50 -0
- package/dist/cjs/with-wizard-screen/style.scss +15 -118
- package/dist/cjs/wizard/breadcrumbs-select.js +70 -0
- package/dist/cjs/wizard/breadcrumbs-select.test.js +200 -0
- package/dist/cjs/wizard/components/WizardError.js +8 -4
- package/dist/cjs/wizard/index.js +162 -141
- package/dist/esm/accordion/index.js +42 -26
- package/dist/esm/accordion/index.test.js +79 -0
- package/dist/esm/accordion/style.scss +25 -21
- package/dist/esm/action-card/style.scss +0 -22
- package/dist/esm/breadcrumbs/index.js +75 -0
- package/dist/esm/breadcrumbs/index.test.js +105 -0
- package/dist/esm/breadcrumbs/style.scss +41 -0
- package/dist/esm/card/core-card.js +8 -4
- package/dist/esm/card/index.js +1 -1
- package/dist/esm/card/style-core.scss +14 -0
- package/dist/esm/card/style.scss +0 -9
- package/dist/esm/card-feature/index.js +25 -13
- package/dist/esm/card-settings-group/index.js +3 -0
- package/dist/esm/confirm-dialog/index.js +34 -8
- package/dist/esm/confirm-dialog/index.test.js +173 -0
- package/dist/esm/divider/style.scss +4 -2
- package/dist/esm/grid/style.scss +5 -0
- package/dist/esm/handoff/index.js +7 -5
- package/dist/esm/index.js +5 -1
- package/dist/esm/integration-icon/index.js +34 -0
- package/dist/esm/integration-icon/style.scss +42 -0
- package/dist/esm/integration-icons/index.js +22 -1
- package/dist/esm/modal/style.scss +4 -0
- package/dist/esm/page/index.js +113 -0
- package/dist/esm/page/index.test.js +65 -0
- package/dist/esm/page/style.scss +86 -0
- package/dist/esm/route-match.js +51 -0
- package/dist/esm/route-match.test.js +74 -0
- package/dist/esm/section-header/index.js +8 -1
- package/dist/esm/section-header/style.scss +11 -11
- package/dist/esm/select-control/GroupedSelectControl.js +1 -1
- package/dist/esm/select-control/style.scss +4 -0
- package/dist/esm/style.scss +1 -4
- package/dist/esm/tabbed-navigation/index.js +192 -38
- package/dist/esm/tabbed-navigation/index.test.js +296 -0
- package/dist/esm/tabbed-navigation/style.scss +77 -57
- package/dist/esm/with-wizard/index.js +33 -45
- package/dist/esm/with-wizard-screen/index.js +78 -41
- package/dist/esm/with-wizard-screen/index.test.js +46 -0
- package/dist/esm/with-wizard-screen/style.scss +15 -118
- package/dist/esm/wizard/breadcrumbs-select.js +63 -0
- package/dist/esm/wizard/breadcrumbs-select.test.js +197 -0
- package/dist/esm/wizard/components/WizardError.js +7 -5
- package/dist/esm/wizard/index.js +165 -144
- package/package.json +10 -2
- package/src/accordion/index.js +27 -15
- package/src/accordion/index.test.js +62 -0
- package/src/accordion/style.scss +25 -21
- package/src/action-card/style.scss +0 -22
- package/src/breadcrumbs/index.js +63 -0
- package/src/breadcrumbs/index.test.js +59 -0
- package/src/breadcrumbs/style.scss +41 -0
- package/src/card/core-card.js +11 -6
- package/src/card/index.js +0 -1
- package/src/card/style-core.scss +14 -0
- package/src/card/style.scss +0 -9
- package/src/card-feature/README.md +3 -3
- package/src/card-feature/index.tsx +37 -21
- package/src/card-settings-group/index.tsx +4 -0
- package/src/card-sortable-list/index.tsx +1 -1
- package/src/confirm-dialog/index.test.js +142 -0
- package/src/confirm-dialog/index.tsx +34 -7
- package/src/divider/style.scss +4 -2
- package/src/grid/style.scss +5 -0
- package/src/handoff/index.js +4 -3
- package/src/index.js +5 -1
- package/src/integration-icon/index.tsx +35 -0
- package/src/integration-icon/style.scss +42 -0
- package/src/integration-icons/index.js +23 -0
- package/src/modal/style.scss +4 -0
- package/src/page/index.js +89 -0
- package/src/page/index.test.js +43 -0
- package/src/page/style.scss +86 -0
- package/src/route-match.js +47 -0
- package/src/route-match.test.js +50 -0
- package/src/section-header/index.js +7 -3
- package/src/section-header/style.scss +11 -11
- package/src/select-control/GroupedSelectControl.js +1 -1
- package/src/select-control/style.scss +4 -0
- package/src/style.scss +1 -4
- package/src/tabbed-navigation/index.js +181 -34
- package/src/tabbed-navigation/index.test.js +228 -0
- package/src/tabbed-navigation/style.scss +77 -57
- package/src/with-wizard/index.js +18 -39
- package/src/with-wizard-screen/index.js +70 -35
- package/src/with-wizard-screen/index.test.js +36 -0
- package/src/with-wizard-screen/style.scss +15 -118
- package/src/wizard/breadcrumbs-select.js +51 -0
- package/src/wizard/breadcrumbs-select.test.js +124 -0
- package/src/wizard/components/WizardError.js +4 -3
- package/src/wizard/index.js +160 -146
- package/tsconfig.json +7 -0
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WordPress dependencies.
|
|
3
|
+
*/
|
|
4
|
+
import { __ } from '@wordpress/i18n';
|
|
5
|
+
import { Link, Stack, Text } from '@wordpress/ui';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Internal dependencies.
|
|
9
|
+
*/
|
|
10
|
+
import './style.scss';
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Newspack breadcrumb trail.
|
|
14
|
+
*
|
|
15
|
+
* Data-driven: the last item is the current page (the single h1, never a link);
|
|
16
|
+
* every other item renders as a link when it has a `url`, otherwise plain text.
|
|
17
|
+
* A plain `<a href>` drives both full admin URLs and hash routes, so the
|
|
18
|
+
* component needs no router context.
|
|
19
|
+
*
|
|
20
|
+
* @param {Object} props
|
|
21
|
+
* @param {Array} props.items Trail items: `{ label, url? }`.
|
|
22
|
+
* @return {JSX.Element|null} Breadcrumbs component.
|
|
23
|
+
*/
|
|
24
|
+
const Breadcrumbs = ( { items = [] } ) => {
|
|
25
|
+
if ( ! items.length ) {
|
|
26
|
+
return null;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const preceding = items.slice( 0, -1 );
|
|
30
|
+
const last = items[ items.length - 1 ];
|
|
31
|
+
|
|
32
|
+
return (
|
|
33
|
+
<nav aria-label={ __( 'Breadcrumbs', 'newspack-plugin' ) }>
|
|
34
|
+
<Stack render={ <ul /> } direction="row" align="center" className="newspack-breadcrumbs__list">
|
|
35
|
+
{ preceding.map( ( item, index ) => (
|
|
36
|
+
<li key={ item.url || index }>
|
|
37
|
+
{ 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>
|
|
42
|
+
) : (
|
|
43
|
+
<Text variant="body-lg">{ item.label }</Text>
|
|
44
|
+
) }
|
|
45
|
+
<Text variant="body-lg" aria-hidden="true" className="newspack-breadcrumbs__separator">
|
|
46
|
+
/
|
|
47
|
+
</Text>
|
|
48
|
+
</li>
|
|
49
|
+
) ) }
|
|
50
|
+
{ last && last.label && (
|
|
51
|
+
<li>
|
|
52
|
+
{ /* eslint-disable-next-line jsx-a11y/heading-has-content -- content is supplied via the Text children through @wordpress/ui's render prop. */ }
|
|
53
|
+
<Text variant="heading-lg" render={ <h1 /> } className="newspack-breadcrumbs__current">
|
|
54
|
+
{ last.label }
|
|
55
|
+
</Text>
|
|
56
|
+
</li>
|
|
57
|
+
) }
|
|
58
|
+
</Stack>
|
|
59
|
+
</nav>
|
|
60
|
+
);
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
export default Breadcrumbs;
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* External dependencies.
|
|
3
|
+
*/
|
|
4
|
+
import { render, screen } from '@testing-library/react';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Internal dependencies.
|
|
8
|
+
*/
|
|
9
|
+
import Breadcrumbs from './';
|
|
10
|
+
|
|
11
|
+
describe( 'Breadcrumbs', () => {
|
|
12
|
+
it( 'renders the last item as the only h1 and never a link', () => {
|
|
13
|
+
render( <Breadcrumbs items={ [ { label: 'Newsletters' }, { label: 'Settings', url: '#/x' } ] } /> );
|
|
14
|
+
const h1s = screen.getAllByRole( 'heading', { level: 1 } );
|
|
15
|
+
expect( h1s ).toHaveLength( 1 );
|
|
16
|
+
expect( h1s[ 0 ] ).toHaveTextContent( 'Settings' );
|
|
17
|
+
expect( screen.queryByRole( 'link', { name: 'Settings' } ) ).not.toBeInTheDocument();
|
|
18
|
+
} );
|
|
19
|
+
|
|
20
|
+
it( 'renders a non-last item with a url as a link, without a url as text', () => {
|
|
21
|
+
render( <Breadcrumbs items={ [ { label: 'Advertising' }, { label: 'Sponsors', url: '/wp-admin/x' }, { label: 'All sponsors' } ] } /> );
|
|
22
|
+
const links = screen.getAllByRole( 'link' );
|
|
23
|
+
expect( links ).toHaveLength( 1 );
|
|
24
|
+
expect( links[ 0 ] ).toHaveTextContent( 'Sponsors' );
|
|
25
|
+
expect( links[ 0 ].getAttribute( 'href' ) ).toBe( '/wp-admin/x' );
|
|
26
|
+
expect( screen.getByText( 'Advertising' ) ).toBeInTheDocument();
|
|
27
|
+
} );
|
|
28
|
+
|
|
29
|
+
it( 'does not special-case the first item: it links when it has a url', () => {
|
|
30
|
+
render( <Breadcrumbs items={ [ { label: 'Audience', url: '#/' }, { label: 'Donations' } ] } /> );
|
|
31
|
+
const link = screen.getByRole( 'link', { name: 'Audience' } );
|
|
32
|
+
expect( link.getAttribute( 'href' ) ).toBe( '#/' );
|
|
33
|
+
} );
|
|
34
|
+
|
|
35
|
+
it( 'renders a single-item trail as just the h1 with no separator', () => {
|
|
36
|
+
const { container } = render( <Breadcrumbs items={ [ { label: 'Dashboard' } ] } /> );
|
|
37
|
+
expect( screen.getByRole( 'heading', { level: 1 } ) ).toHaveTextContent( 'Dashboard' );
|
|
38
|
+
expect( container.querySelector( '.newspack-breadcrumbs__separator' ) ).toBeNull();
|
|
39
|
+
expect( screen.queryByRole( 'link' ) ).not.toBeInTheDocument();
|
|
40
|
+
} );
|
|
41
|
+
|
|
42
|
+
it( 'renders a "/" separator after each preceding item', () => {
|
|
43
|
+
const { container } = render( <Breadcrumbs items={ [ { label: 'Audience' }, { label: 'Access control' } ] } /> );
|
|
44
|
+
const separators = container.querySelectorAll( '.newspack-breadcrumbs__separator' );
|
|
45
|
+
expect( separators ).toHaveLength( 1 );
|
|
46
|
+
expect( separators[ 0 ] ).toHaveTextContent( '/' );
|
|
47
|
+
expect( separators[ 0 ] ).toHaveAttribute( 'aria-hidden', 'true' );
|
|
48
|
+
} );
|
|
49
|
+
|
|
50
|
+
it( 'renders no heading when the last item has no label', () => {
|
|
51
|
+
render( <Breadcrumbs items={ [ { label: undefined } ] } /> );
|
|
52
|
+
expect( screen.queryByRole( 'heading', { level: 1 } ) ).not.toBeInTheDocument();
|
|
53
|
+
} );
|
|
54
|
+
|
|
55
|
+
it( 'renders nothing when there are no items', () => {
|
|
56
|
+
const { container } = render( <Breadcrumbs items={ [] } /> );
|
|
57
|
+
expect( container.querySelector( 'nav' ) ).not.toBeInTheDocument();
|
|
58
|
+
} );
|
|
59
|
+
} );
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
@use "~@wordpress/base-styles/colors" as wp-colors;
|
|
2
|
+
|
|
3
|
+
.newspack-breadcrumbs__list {
|
|
4
|
+
list-style: none;
|
|
5
|
+
margin: 0;
|
|
6
|
+
padding: 0;
|
|
7
|
+
|
|
8
|
+
> li {
|
|
9
|
+
flex-shrink: 0;
|
|
10
|
+
margin: 0;
|
|
11
|
+
|
|
12
|
+
a {
|
|
13
|
+
color: wp-colors.$gray-900;
|
|
14
|
+
|
|
15
|
+
&:hover,
|
|
16
|
+
&:focus,
|
|
17
|
+
&:active {
|
|
18
|
+
color: wp-colors.$gray-900;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
> li:last-child {
|
|
24
|
+
flex-shrink: 1;
|
|
25
|
+
min-width: 0;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
.newspack-breadcrumbs__separator {
|
|
30
|
+
color: var(--wpds-color-stroke-surface-neutral, #dbdbdb);
|
|
31
|
+
margin: 0 var(--wpds-dimension-gap-sm, 8px);
|
|
32
|
+
user-select: none;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
.newspack-breadcrumbs__list h1.newspack-breadcrumbs__current {
|
|
36
|
+
overflow: hidden;
|
|
37
|
+
text-overflow: ellipsis;
|
|
38
|
+
white-space: nowrap;
|
|
39
|
+
padding: 0;
|
|
40
|
+
line-height: normal;
|
|
41
|
+
}
|
package/src/card/core-card.js
CHANGED
|
@@ -34,6 +34,7 @@ const CoreCard = ( {
|
|
|
34
34
|
footerStyle,
|
|
35
35
|
disabled,
|
|
36
36
|
icon,
|
|
37
|
+
iconElement,
|
|
37
38
|
iconBackgroundColor,
|
|
38
39
|
isActive,
|
|
39
40
|
isDraggable,
|
|
@@ -60,7 +61,7 @@ const CoreCard = ( {
|
|
|
60
61
|
isDraggable && 'newspack-card--core__is-draggable',
|
|
61
62
|
isNarrow && 'newspack-card--core__is-narrow',
|
|
62
63
|
isSmall && 'newspack-card--core__is-small',
|
|
63
|
-
icon && 'newspack-card--core__has-icon',
|
|
64
|
+
( icon || iconElement ) && 'newspack-card--core__has-icon',
|
|
64
65
|
iconBackgroundColor && 'newspack-card--core__has-icon-background-color',
|
|
65
66
|
isActive && 'newspack-card--core__is-active',
|
|
66
67
|
disabled && 'newspack-card--core__is-disabled',
|
|
@@ -87,7 +88,7 @@ const CoreCard = ( {
|
|
|
87
88
|
const headerIsButton = !! onHeaderClick && ! hasInteractiveHeaderChildren;
|
|
88
89
|
return (
|
|
89
90
|
<CardWrapper as={ as } className={ classes } { ...otherProps }>
|
|
90
|
-
{ ( header || icon ) && (
|
|
91
|
+
{ ( header || icon || iconElement ) && (
|
|
91
92
|
<CardHeader
|
|
92
93
|
as={ headerIsButton ? 'button' : undefined }
|
|
93
94
|
className={ classNames(
|
|
@@ -125,10 +126,14 @@ const CoreCard = ( {
|
|
|
125
126
|
</div>
|
|
126
127
|
</div>
|
|
127
128
|
) }
|
|
128
|
-
{
|
|
129
|
-
<div className="newspack-card--core__icon">
|
|
130
|
-
|
|
131
|
-
|
|
129
|
+
{ iconElement ? (
|
|
130
|
+
<div className="newspack-card--core__icon-slot">{ iconElement }</div>
|
|
131
|
+
) : (
|
|
132
|
+
icon && (
|
|
133
|
+
<div className="newspack-card--core__icon">
|
|
134
|
+
<Icon icon={ icon } height={ isSmall ? 24 : 48 } width={ isSmall ? 24 : 48 } />
|
|
135
|
+
</div>
|
|
136
|
+
)
|
|
132
137
|
) }
|
|
133
138
|
{ actions?.length > 0 && actionType === 'toggle' && (
|
|
134
139
|
<ToggleControl
|
package/src/card/index.js
CHANGED
|
@@ -65,7 +65,6 @@ class Card extends Component {
|
|
|
65
65
|
const classes = classNames(
|
|
66
66
|
'newspack-card',
|
|
67
67
|
className,
|
|
68
|
-
buttonsCard && 'newspack-card__buttons-card',
|
|
69
68
|
headerActions && 'newspack-card__header-actions',
|
|
70
69
|
isMedium && 'newspack-card__is-medium',
|
|
71
70
|
isNarrow && 'newspack-card__is-narrow',
|
package/src/card/style-core.scss
CHANGED
|
@@ -14,6 +14,11 @@
|
|
|
14
14
|
svg {
|
|
15
15
|
transition: fill 125ms ease-in-out;
|
|
16
16
|
}
|
|
17
|
+
// Slot for a pre-rendered icon element that owns its own badge.
|
|
18
|
+
&__icon-slot {
|
|
19
|
+
align-items: center;
|
|
20
|
+
display: flex;
|
|
21
|
+
}
|
|
17
22
|
&__icon {
|
|
18
23
|
border-radius: wp-vars.$radius-round;
|
|
19
24
|
display: grid;
|
|
@@ -149,6 +154,15 @@
|
|
|
149
154
|
border: none;
|
|
150
155
|
cursor: pointer;
|
|
151
156
|
text-align: left;
|
|
157
|
+
|
|
158
|
+
// The header becomes a real button (headerIsButton) with no border, so
|
|
159
|
+
// give keyboard focus an explicit on-brand ring. Inset so it isn't clipped
|
|
160
|
+
// by the card; the transparent outline keeps it visible under Windows High
|
|
161
|
+
// Contrast / forced-colors mode.
|
|
162
|
+
&:focus-visible {
|
|
163
|
+
box-shadow: inset 0 0 0 2px var(--wp-admin-theme-color);
|
|
164
|
+
outline: 2px solid transparent;
|
|
165
|
+
}
|
|
152
166
|
}
|
|
153
167
|
&__header-content {
|
|
154
168
|
display: grid;
|
package/src/card/style.scss
CHANGED
|
@@ -11,7 +11,7 @@ A card component for presenting a named feature or setting with a predictable, s
|
|
|
11
11
|
|
|
12
12
|
| State | Condition | Button | Dropdown | Badge |
|
|
13
13
|
|---|---|---|---|---|
|
|
14
|
-
| **Unmet requirements** | `requirements` is set | "Enable" — disabled (clickable if `requirementsActionable`) |
|
|
14
|
+
| **Unmet requirements** | `requirements` is set | "Enable" — disabled (clickable if `requirementsActionable`) | Shown if `enabled` and `requirementsActionable` (and `moreControls` provided); otherwise hidden | Error badge with `requirements` text |
|
|
15
15
|
| **Disabled** | `!enabled`, no requirements | "Enable" | Hidden | None |
|
|
16
16
|
| **Enabled** | `enabled`, no requirements | "Configure" | Shown if `moreControls` provided | Success badge ("Enabled") |
|
|
17
17
|
|
|
@@ -155,12 +155,12 @@ import { __ } from '@wordpress/i18n';
|
|
|
155
155
|
| `icon` | `CardFeatureIcon` | — | Icon displayed on the right. See `CardFeatureIcon` below. |
|
|
156
156
|
| `enabled` | `boolean` | `false` | Whether the feature is currently enabled |
|
|
157
157
|
| `requirements` | `string` | — | When set, enters the unmet-requirements state; value is used as the error badge text |
|
|
158
|
-
| `requirementsActionable` | `boolean` | `false` | When `requirements` is set, keep the primary button clickable so it can remediate the unmet requirement |
|
|
158
|
+
| `requirementsActionable` | `boolean` | `false` | When `requirements` is set, keep the primary button clickable so it can remediate the unmet requirement, and keep the "More" dropdown visible on an enabled card (degraded but still operable) |
|
|
159
159
|
| `enableLabel` | `string` | `"Enable"` | Primary button label when not enabled |
|
|
160
160
|
| `configureLabel` | `string` | `"Configure"` | Primary button label when enabled |
|
|
161
161
|
| `onEnable` | `() => void` | — | Called when the primary button is clicked and the feature is not enabled |
|
|
162
162
|
| `onConfigure` | `() => void` | — | Called when the primary button is clicked and the feature is enabled |
|
|
163
|
-
| `moreControls` | `MoreControl[]` | — | Items for the "More" dropdown
|
|
163
|
+
| `moreControls` | `MoreControl[]` | — | Items for the "More" dropdown. Shown when `enabled` and either there are no `requirements` or `requirementsActionable` is set |
|
|
164
164
|
| `badgeText` | `string` | `"Enabled"` | Badge text shown when enabled |
|
|
165
165
|
| `badgeLevel` | `BadgeLevel` | `"success"` | Badge level shown when enabled |
|
|
166
166
|
| `className` | `string` | — | Additional class name applied to the card element |
|
|
@@ -7,6 +7,7 @@ import classnames from 'classnames';
|
|
|
7
7
|
* WordPress dependencies
|
|
8
8
|
*/
|
|
9
9
|
import { __ } from '@wordpress/i18n';
|
|
10
|
+
import { isValidElement } from '@wordpress/element';
|
|
10
11
|
import { DropdownMenu, __experimentalHStack as HStack } from '@wordpress/components'; // eslint-disable-line @wordpress/no-unsafe-wp-apis
|
|
11
12
|
import { moreVertical } from '@wordpress/icons';
|
|
12
13
|
|
|
@@ -38,14 +39,14 @@ type CardFeatureIcon = {
|
|
|
38
39
|
type MoreControl = {
|
|
39
40
|
title: string;
|
|
40
41
|
onClick: () => void;
|
|
41
|
-
icon?:
|
|
42
|
+
icon?: JSX.Element;
|
|
42
43
|
};
|
|
43
44
|
|
|
44
45
|
type CardFeatureProps = {
|
|
45
46
|
title: string;
|
|
46
47
|
description?: string;
|
|
47
|
-
/** Icon
|
|
48
|
-
icon?: CardFeatureIcon;
|
|
48
|
+
/** Icon shown beside the title: a descriptor (coloured badge) or a ready element rendered as-is. */
|
|
49
|
+
icon?: CardFeatureIcon | React.ReactElement;
|
|
49
50
|
/** Whether the feature is currently enabled. */
|
|
50
51
|
enabled?: boolean;
|
|
51
52
|
/**
|
|
@@ -57,18 +58,22 @@ type CardFeatureProps = {
|
|
|
57
58
|
requirements?: string;
|
|
58
59
|
/**
|
|
59
60
|
* When `requirements` is set, keep the primary button clickable so the
|
|
60
|
-
* user can remediate the unmet requirement from this card
|
|
61
|
+
* user can remediate the unmet requirement from this card, and keep the
|
|
62
|
+
* "More" dropdown available — the feature is degraded but still operable
|
|
63
|
+
* (e.g. can be disabled), unlike a hard-locked requirement.
|
|
61
64
|
*/
|
|
62
65
|
requirementsActionable?: boolean;
|
|
63
66
|
/** Primary button label when not enabled. Default: "Enable". */
|
|
64
67
|
enableLabel?: string;
|
|
68
|
+
/** Show the primary button as busy (spinner) and disabled while an action is in flight. */
|
|
69
|
+
busy?: boolean;
|
|
65
70
|
/** Primary button label when enabled. Default: "Configure". */
|
|
66
71
|
configureLabel?: string;
|
|
67
72
|
/** Called when the primary button is clicked and the feature is not enabled. */
|
|
68
73
|
onEnable?: () => void;
|
|
69
74
|
/** Called when the primary button is clicked and the feature is enabled. */
|
|
70
75
|
onConfigure?: () => void;
|
|
71
|
-
/** Controls rendered inside the "More" dropdown, shown
|
|
76
|
+
/** Controls rendered inside the "More" dropdown, shown when enabled — including the unmet-requirements state when `requirementsActionable`. */
|
|
72
77
|
moreControls?: MoreControl[];
|
|
73
78
|
/** Badge text shown when enabled. Default: "Enabled". */
|
|
74
79
|
badgeText?: string;
|
|
@@ -92,6 +97,7 @@ const CardFeature = ( {
|
|
|
92
97
|
requirements,
|
|
93
98
|
requirementsActionable = false,
|
|
94
99
|
enableLabel,
|
|
100
|
+
busy = false,
|
|
95
101
|
configureLabel,
|
|
96
102
|
onEnable,
|
|
97
103
|
onConfigure,
|
|
@@ -114,6 +120,7 @@ const CardFeature = ( {
|
|
|
114
120
|
|
|
115
121
|
const isConfigureState = enabled && ! requirements;
|
|
116
122
|
const buttonLabel = isConfigureState ? configureLabel ?? __( 'Configure', 'newspack-plugin' ) : enableLabel ?? __( 'Enable', 'newspack-plugin' );
|
|
123
|
+
const showMoreControls = enabled && !! moreControls?.length && ( ! requirements || requirementsActionable );
|
|
117
124
|
|
|
118
125
|
const handleButtonClick = () => {
|
|
119
126
|
if ( isConfigureState ) {
|
|
@@ -123,13 +130,31 @@ const CardFeature = ( {
|
|
|
123
130
|
}
|
|
124
131
|
};
|
|
125
132
|
|
|
126
|
-
const
|
|
133
|
+
const iconDescriptor = icon && ! isValidElement( icon ) ? ( icon as CardFeatureIcon ) : null;
|
|
134
|
+
const iconClasses = iconDescriptor
|
|
127
135
|
? classnames( 'newspack-card-feature__icon', {
|
|
128
|
-
'newspack-card-feature__icon--radius-small': !!
|
|
129
|
-
'newspack-card-feature__icon--radius-full':
|
|
136
|
+
'newspack-card-feature__icon--radius-small': !! iconDescriptor.backgroundColor && iconDescriptor.radius !== 'full',
|
|
137
|
+
'newspack-card-feature__icon--radius-full': iconDescriptor.radius === 'full',
|
|
130
138
|
} )
|
|
131
139
|
: undefined;
|
|
132
140
|
|
|
141
|
+
let renderedIcon = null;
|
|
142
|
+
if ( isValidElement( icon ) ) {
|
|
143
|
+
renderedIcon = icon;
|
|
144
|
+
} else if ( iconDescriptor ) {
|
|
145
|
+
renderedIcon = (
|
|
146
|
+
<div
|
|
147
|
+
className={ iconClasses }
|
|
148
|
+
style={ {
|
|
149
|
+
backgroundColor: iconDescriptor.backgroundColor,
|
|
150
|
+
color: iconDescriptor.fill,
|
|
151
|
+
} }
|
|
152
|
+
>
|
|
153
|
+
{ iconDescriptor.node }
|
|
154
|
+
</div>
|
|
155
|
+
);
|
|
156
|
+
}
|
|
157
|
+
|
|
133
158
|
return (
|
|
134
159
|
<Card
|
|
135
160
|
className={ classes }
|
|
@@ -143,29 +168,20 @@ const CardFeature = ( {
|
|
|
143
168
|
<h2 className="newspack-card-feature__title">{ title }</h2>
|
|
144
169
|
{ description && <p className="newspack-card-feature__description">{ description }</p> }
|
|
145
170
|
</div>
|
|
146
|
-
{
|
|
147
|
-
<div
|
|
148
|
-
className={ iconClasses }
|
|
149
|
-
style={ {
|
|
150
|
-
backgroundColor: icon.backgroundColor,
|
|
151
|
-
color: icon.fill,
|
|
152
|
-
} }
|
|
153
|
-
>
|
|
154
|
-
{ icon.node }
|
|
155
|
-
</div>
|
|
156
|
-
) }
|
|
171
|
+
{ renderedIcon }
|
|
157
172
|
</HStack>
|
|
158
173
|
<HStack alignment="edge">
|
|
159
174
|
<HStack expanded={ false } spacing="8px">
|
|
160
175
|
<Button
|
|
161
176
|
variant={ isConfigureState ? 'tertiary' : 'secondary' }
|
|
162
|
-
disabled={ isMuted && ! requirementsActionable }
|
|
177
|
+
disabled={ ( isMuted && ! requirementsActionable ) || busy }
|
|
178
|
+
isBusy={ busy }
|
|
163
179
|
onClick={ handleButtonClick }
|
|
164
180
|
size="compact"
|
|
165
181
|
>
|
|
166
182
|
{ buttonLabel }
|
|
167
183
|
</Button>
|
|
168
|
-
{
|
|
184
|
+
{ showMoreControls && (
|
|
169
185
|
<DropdownMenu
|
|
170
186
|
icon={ moreVertical }
|
|
171
187
|
label={ __( 'More', 'newspack-plugin' ) }
|
|
@@ -19,6 +19,7 @@ const CardSettingsGroup = ( {
|
|
|
19
19
|
className,
|
|
20
20
|
disabled = false,
|
|
21
21
|
icon = null,
|
|
22
|
+
iconElement = null,
|
|
22
23
|
headerAction,
|
|
23
24
|
title = '',
|
|
24
25
|
description = '',
|
|
@@ -31,6 +32,8 @@ const CardSettingsGroup = ( {
|
|
|
31
32
|
className?: string;
|
|
32
33
|
disabled?: boolean;
|
|
33
34
|
icon?: React.ReactNode;
|
|
35
|
+
/** A ready-rendered icon element (e.g. `<IntegrationIcon>`) shown as-is, without the default icon badge. */
|
|
36
|
+
iconElement?: React.ReactNode;
|
|
34
37
|
title: string;
|
|
35
38
|
headerAction?: {
|
|
36
39
|
label: string;
|
|
@@ -65,6 +68,7 @@ const CardSettingsGroup = ( {
|
|
|
65
68
|
onToggle: onEnable,
|
|
66
69
|
disabled,
|
|
67
70
|
icon,
|
|
71
|
+
iconElement,
|
|
68
72
|
iconBackgroundColor: true,
|
|
69
73
|
isActive,
|
|
70
74
|
title,
|
|
@@ -269,7 +269,7 @@ const CardSortableList = ( {
|
|
|
269
269
|
return itemTops.length; // below all items
|
|
270
270
|
};
|
|
271
271
|
|
|
272
|
-
const handleDragEnd = ( event: DragEvent ) => {
|
|
272
|
+
const handleDragEnd = ( event: React.DragEvent ) => {
|
|
273
273
|
// Take a local copy of measurements before clearDragState nulls it.
|
|
274
274
|
const m = measurements;
|
|
275
275
|
const sourceIndex = draggingIndex;
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* External dependencies.
|
|
3
|
+
*/
|
|
4
|
+
import { act, fireEvent, render, screen } from '@testing-library/react';
|
|
5
|
+
import { MemoryRouter, useHistory } from 'react-router-dom';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Internal dependencies.
|
|
9
|
+
*/
|
|
10
|
+
import ConfirmDialog from './';
|
|
11
|
+
|
|
12
|
+
const HistoryGrabber = ( { historyRef } ) => {
|
|
13
|
+
historyRef.current = useHistory();
|
|
14
|
+
return null;
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
const renderWithHistory = ( routerProps = {} ) => {
|
|
18
|
+
const historyRef = { current: null };
|
|
19
|
+
render(
|
|
20
|
+
<MemoryRouter { ...routerProps }>
|
|
21
|
+
<HistoryGrabber historyRef={ historyRef } />
|
|
22
|
+
<ConfirmDialog when confirmButtonText="Discard changes" cancelButtonText="Keep editing">
|
|
23
|
+
Unsaved changes
|
|
24
|
+
</ConfirmDialog>
|
|
25
|
+
</MemoryRouter>
|
|
26
|
+
);
|
|
27
|
+
return historyRef.current;
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
// The modal's X close button reuses the cancel label as its aria-label, so
|
|
31
|
+
// query by visible text to hit the actual footer buttons.
|
|
32
|
+
const confirmNavigation = () => fireEvent.click( screen.getByText( 'Discard changes', { selector: 'button' } ) );
|
|
33
|
+
const cancelNavigation = () => fireEvent.click( screen.getByText( 'Keep editing', { selector: 'button' } ) );
|
|
34
|
+
const dialog = () => screen.queryByText( 'Unsaved changes' );
|
|
35
|
+
|
|
36
|
+
describe( 'ConfirmDialog navigation blocking', () => {
|
|
37
|
+
it( 'blocks navigation and stays on the current page on cancel', () => {
|
|
38
|
+
const history = renderWithHistory();
|
|
39
|
+
act( () => history.push( '/next' ) );
|
|
40
|
+
expect( dialog() ).toBeInTheDocument();
|
|
41
|
+
expect( history.location.pathname ).toBe( '/' );
|
|
42
|
+
|
|
43
|
+
const replaceSpy = jest.spyOn( history, 'replace' );
|
|
44
|
+
cancelNavigation();
|
|
45
|
+
expect( dialog() ).not.toBeInTheDocument();
|
|
46
|
+
// The URL is re-synced to the page the user chose to stay on.
|
|
47
|
+
expect( replaceSpy ).toHaveBeenCalledTimes( 1 );
|
|
48
|
+
expect( history.location.pathname ).toBe( '/' );
|
|
49
|
+
} );
|
|
50
|
+
|
|
51
|
+
it( 'replays a blocked push on confirm: re-sync replace first, then push, and re-arms the blocker', () => {
|
|
52
|
+
const history = renderWithHistory();
|
|
53
|
+
const replaceSpy = jest.spyOn( history, 'replace' );
|
|
54
|
+
const pushSpy = jest.spyOn( history, 'push' );
|
|
55
|
+
|
|
56
|
+
act( () => history.push( '/next' ) );
|
|
57
|
+
expect( history.location.pathname ).toBe( '/' );
|
|
58
|
+
confirmNavigation();
|
|
59
|
+
expect( history.location.pathname ).toBe( '/next' );
|
|
60
|
+
|
|
61
|
+
// Replay order: re-sync replace fires before the target push.
|
|
62
|
+
const resyncOrder = replaceSpy.mock.invocationCallOrder[ 0 ];
|
|
63
|
+
const replayOrder = pushSpy.mock.invocationCallOrder[ 1 ];
|
|
64
|
+
expect( replaceSpy ).toHaveBeenCalledTimes( 1 );
|
|
65
|
+
expect( pushSpy ).toHaveBeenCalledTimes( 2 );
|
|
66
|
+
expect( resyncOrder ).toBeLessThan( replayOrder );
|
|
67
|
+
|
|
68
|
+
// The bypass only lets the confirmed navigation through; the next one is blocked again.
|
|
69
|
+
act( () => history.push( '/another' ) );
|
|
70
|
+
expect( history.location.pathname ).toBe( '/next' );
|
|
71
|
+
expect( dialog() ).toBeInTheDocument();
|
|
72
|
+
} );
|
|
73
|
+
|
|
74
|
+
it( 'replays a blocked REPLACE with replace, not push', () => {
|
|
75
|
+
const history = renderWithHistory();
|
|
76
|
+
const pushSpy = jest.spyOn( history, 'push' );
|
|
77
|
+
|
|
78
|
+
act( () => history.replace( '/replaced' ) );
|
|
79
|
+
expect( dialog() ).toBeInTheDocument();
|
|
80
|
+
expect( history.location.pathname ).toBe( '/' );
|
|
81
|
+
|
|
82
|
+
confirmNavigation();
|
|
83
|
+
expect( history.location.pathname ).toBe( '/replaced' );
|
|
84
|
+
expect( pushSpy ).not.toHaveBeenCalled();
|
|
85
|
+
expect( history.length ).toBe( 1 );
|
|
86
|
+
} );
|
|
87
|
+
|
|
88
|
+
it( 'replays a blocked POP (back navigation) as a push after the re-sync', () => {
|
|
89
|
+
const history = renderWithHistory( { initialEntries: [ '/start', '/current' ], initialIndex: 1 } );
|
|
90
|
+
act( () => history.goBack() );
|
|
91
|
+
expect( dialog() ).toBeInTheDocument();
|
|
92
|
+
expect( history.location.pathname ).toBe( '/current' );
|
|
93
|
+
|
|
94
|
+
confirmNavigation();
|
|
95
|
+
expect( history.location.pathname ).toBe( '/start' );
|
|
96
|
+
// Pushed rather than popped: the target becomes a new forward entry.
|
|
97
|
+
expect( history.length ).toBe( 3 );
|
|
98
|
+
} );
|
|
99
|
+
|
|
100
|
+
// React re-reports errors thrown in event handlers as uncaught window
|
|
101
|
+
// errors; swallow them so the intentional throws don't fail the test.
|
|
102
|
+
const withNavigationError = fn => {
|
|
103
|
+
const consoleError = jest.spyOn( console, 'error' ).mockImplementation( () => {} );
|
|
104
|
+
const onWindowError = event => event.preventDefault();
|
|
105
|
+
window.addEventListener( 'error', onWindowError );
|
|
106
|
+
try {
|
|
107
|
+
fn();
|
|
108
|
+
} finally {
|
|
109
|
+
window.removeEventListener( 'error', onWindowError );
|
|
110
|
+
consoleError.mockRestore();
|
|
111
|
+
}
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
it( 'keeps blocking after a confirmed navigation throws', () => {
|
|
115
|
+
const history = renderWithHistory();
|
|
116
|
+
|
|
117
|
+
act( () => history.push( '/next' ) );
|
|
118
|
+
jest.spyOn( history, 'push' ).mockImplementationOnce( () => {
|
|
119
|
+
throw new Error( 'nav failed' );
|
|
120
|
+
} );
|
|
121
|
+
withNavigationError( () => confirmNavigation() );
|
|
122
|
+
|
|
123
|
+
// bypassBlock was reset by the finally, so navigation is still protected.
|
|
124
|
+
act( () => history.push( '/elsewhere' ) );
|
|
125
|
+
expect( history.location.pathname ).toBe( '/' );
|
|
126
|
+
expect( dialog() ).toBeInTheDocument();
|
|
127
|
+
} );
|
|
128
|
+
|
|
129
|
+
it( 'keeps blocking after a cancel URL re-sync throws', () => {
|
|
130
|
+
const history = renderWithHistory();
|
|
131
|
+
|
|
132
|
+
act( () => history.push( '/next' ) );
|
|
133
|
+
jest.spyOn( history, 'replace' ).mockImplementationOnce( () => {
|
|
134
|
+
throw new Error( 'replace failed' );
|
|
135
|
+
} );
|
|
136
|
+
withNavigationError( () => cancelNavigation() );
|
|
137
|
+
|
|
138
|
+
act( () => history.push( '/elsewhere' ) );
|
|
139
|
+
expect( history.location.pathname ).toBe( '/' );
|
|
140
|
+
expect( dialog() ).toBeInTheDocument();
|
|
141
|
+
} );
|
|
142
|
+
} );
|
|
@@ -58,6 +58,7 @@ function ConfirmDialog(
|
|
|
58
58
|
onCancel = noOp,
|
|
59
59
|
when = false,
|
|
60
60
|
isOpen = false,
|
|
61
|
+
children,
|
|
61
62
|
...otherProps
|
|
62
63
|
}: ConfirmDialogProps,
|
|
63
64
|
ref: React.Ref< HTMLDivElement >
|
|
@@ -65,6 +66,9 @@ function ConfirmDialog(
|
|
|
65
66
|
const [ showDialog, setShowDialog ] = useState( isOpen );
|
|
66
67
|
const history = useHistory();
|
|
67
68
|
const pendingNavigation = useRef< ( () => void ) | null >( null );
|
|
69
|
+
// While true, the blocker lets navigation through — used so the confirmed
|
|
70
|
+
// ("Discard changes") navigation isn't caught by the still-active blocker.
|
|
71
|
+
const bypassBlock = useRef( false );
|
|
68
72
|
|
|
69
73
|
const handleOnConfirm = useCallback( () => {
|
|
70
74
|
setShowDialog( false );
|
|
@@ -76,8 +80,16 @@ function ConfirmDialog(
|
|
|
76
80
|
const handleOnCancel = useCallback( () => {
|
|
77
81
|
setShowDialog( false );
|
|
78
82
|
pendingNavigation.current = null;
|
|
83
|
+
// A POP may have moved the URL to the target before it was blocked; put
|
|
84
|
+
// it back so the address bar matches the page the user chose to stay on.
|
|
85
|
+
bypassBlock.current = true;
|
|
86
|
+
try {
|
|
87
|
+
history.replace( history.location );
|
|
88
|
+
} finally {
|
|
89
|
+
bypassBlock.current = false;
|
|
90
|
+
}
|
|
79
91
|
onCancel();
|
|
80
|
-
}, [ onCancel,
|
|
92
|
+
}, [ onCancel, history ] );
|
|
81
93
|
|
|
82
94
|
// Block navigation when there are unsaved changes.
|
|
83
95
|
useEffect( () => {
|
|
@@ -85,12 +97,25 @@ function ConfirmDialog(
|
|
|
85
97
|
return;
|
|
86
98
|
}
|
|
87
99
|
const unblock = history.block( ( location: string, action: string ) => {
|
|
100
|
+
// Let our own confirmed navigation through instead of re-blocking it.
|
|
101
|
+
if ( bypassBlock.current ) {
|
|
102
|
+
return undefined;
|
|
103
|
+
}
|
|
88
104
|
pendingNavigation.current = () => {
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
105
|
+
bypassBlock.current = true;
|
|
106
|
+
try {
|
|
107
|
+
// A browser/anchor POP (e.g. a breadcrumb or back link) moves the URL
|
|
108
|
+
// to the target before navigation is blocked, and v5 leaves the hash
|
|
109
|
+
// there, so pushing the target would be a no-op. Re-sync to the
|
|
110
|
+
// still-current location first so the real navigation actually fires.
|
|
111
|
+
history.replace( history.location );
|
|
112
|
+
if ( action === 'REPLACE' ) {
|
|
113
|
+
history.replace( location );
|
|
114
|
+
} else {
|
|
115
|
+
history.push( location );
|
|
116
|
+
}
|
|
117
|
+
} finally {
|
|
118
|
+
bypassBlock.current = false;
|
|
94
119
|
}
|
|
95
120
|
};
|
|
96
121
|
setShowDialog( true );
|
|
@@ -126,7 +151,9 @@ function ConfirmDialog(
|
|
|
126
151
|
onConfirm={ handleOnConfirm }
|
|
127
152
|
onCancel={ handleOnCancel }
|
|
128
153
|
__experimentalHideHeader={ false }
|
|
129
|
-
|
|
154
|
+
>
|
|
155
|
+
{ children }
|
|
156
|
+
</BaseComponent>
|
|
130
157
|
);
|
|
131
158
|
}
|
|
132
159
|
export default forwardRef( ConfirmDialog );
|