newspack-components 4.8.0 → 4.9.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 +10 -0
- package/DEVELOPMENT.md +18 -17
- package/dist/cjs/action-card/index.js +85 -18
- package/dist/cjs/action-card/index.test.js +192 -5
- package/dist/cjs/action-card/style.scss +3 -9
- package/dist/cjs/card-feature/index.js +28 -3
- package/dist/cjs/card-feature/index.test.js +100 -45
- package/dist/cjs/card-feature/style.scss +1 -5
- package/dist/cjs/debug-badge/index.js +44 -0
- package/dist/cjs/debug-badge/index.test.js +50 -0
- package/dist/cjs/debug-badge/style.scss +26 -0
- package/dist/cjs/empty-state/index.test.js +37 -24
- package/dist/cjs/empty-state/root.js +2 -1
- package/dist/cjs/footer/index.js +52 -16
- package/dist/cjs/footer/index.test.js +208 -0
- package/dist/cjs/footer/style.scss +17 -1
- package/dist/cjs/index.js +7 -0
- package/dist/cjs/integration-icon/index.js +4 -4
- package/dist/cjs/integration-icon/style.scss +18 -2
- package/dist/cjs/integration-icons/active-campaign.js +4 -7
- package/dist/cjs/integration-icons/beehiiv.js +19 -0
- package/dist/cjs/integration-icons/gravity-forms.js +19 -0
- package/dist/cjs/integration-icons/index.js +26 -6
- package/dist/cjs/integration-icons/salesforce.js +5 -3
- package/dist/cjs/notice/index.js +1 -4
- package/dist/cjs/notice/style.scss +0 -19
- package/dist/cjs/plugin-toggle/index.js +1 -1
- package/dist/cjs/with-wizard-screen/index.js +1 -3
- package/dist/cjs/with-wizard-screen/index.test.js +0 -3
- package/dist/cjs/wizard/breadcrumbs-select.js +17 -4
- package/dist/cjs/wizard/breadcrumbs-select.test.js +17 -0
- package/dist/cjs/wizard/index.js +50 -13
- package/dist/cjs/wizard/index.test.js +304 -5
- package/dist/cjs/wizard/store/index.js +2 -0
- package/dist/esm/action-card/index.js +86 -21
- package/dist/esm/action-card/index.test.js +192 -1
- package/dist/esm/action-card/style.scss +3 -9
- package/dist/esm/card-feature/index.js +29 -4
- package/dist/esm/card-feature/index.test.js +100 -45
- package/dist/esm/card-feature/style.scss +1 -5
- package/dist/esm/debug-badge/index.js +39 -0
- package/dist/esm/debug-badge/index.test.js +46 -0
- package/dist/esm/debug-badge/style.scss +26 -0
- package/dist/esm/empty-state/index.test.js +37 -24
- package/dist/esm/empty-state/root.js +2 -1
- package/dist/esm/footer/index.js +52 -17
- package/dist/esm/footer/index.test.js +210 -0
- package/dist/esm/footer/style.scss +17 -1
- package/dist/esm/index.js +1 -0
- package/dist/esm/integration-icon/index.js +5 -5
- package/dist/esm/integration-icon/style.scss +18 -2
- package/dist/esm/integration-icons/active-campaign.js +5 -8
- package/dist/esm/integration-icons/beehiiv.js +13 -0
- package/dist/esm/integration-icons/gravity-forms.js +13 -0
- package/dist/esm/integration-icons/index.js +15 -4
- package/dist/esm/integration-icons/salesforce.js +5 -3
- package/dist/esm/notice/index.js +2 -5
- package/dist/esm/notice/style.scss +0 -19
- package/dist/esm/plugin-toggle/index.js +1 -1
- package/dist/esm/with-wizard-screen/index.js +2 -4
- package/dist/esm/with-wizard-screen/index.test.js +0 -3
- package/dist/esm/wizard/breadcrumbs-select.js +16 -3
- package/dist/esm/wizard/breadcrumbs-select.test.js +18 -1
- package/dist/esm/wizard/index.js +53 -16
- package/dist/esm/wizard/index.test.js +304 -5
- package/dist/esm/wizard/store/index.js +2 -0
- package/package.json +1 -1
- package/src/action-card/action-card.d.ts +3 -2
- package/src/action-card/index.js +81 -8
- package/src/action-card/index.test.js +159 -0
- package/src/action-card/style.scss +3 -9
- package/src/card-feature/README.md +10 -11
- package/src/card-feature/index.test.js +45 -15
- package/src/card-feature/index.tsx +43 -24
- package/src/card-feature/style.scss +1 -5
- package/src/debug-badge/index.js +36 -0
- package/src/debug-badge/index.test.js +39 -0
- package/src/debug-badge/style.scss +26 -0
- package/src/empty-state/README.md +11 -10
- package/src/empty-state/index.test.js +10 -0
- package/src/empty-state/root.tsx +2 -1
- package/src/empty-state/types.ts +1 -1
- package/src/footer/index.js +47 -2
- package/src/footer/index.test.js +114 -0
- package/src/footer/style.scss +17 -1
- package/src/index.js +1 -0
- package/src/integration-icon/index.tsx +5 -5
- package/src/integration-icon/style.scss +18 -2
- package/src/integration-icons/active-campaign.js +1 -5
- package/src/integration-icons/beehiiv.js +10 -0
- package/src/integration-icons/gravity-forms.js +10 -0
- package/src/integration-icons/index.js +14 -3
- package/src/integration-icons/salesforce.js +3 -3
- package/src/notice/index.js +2 -17
- package/src/notice/style.scss +0 -19
- package/src/plugin-toggle/index.js +1 -1
- package/src/with-wizard-screen/index.js +2 -2
- package/src/with-wizard-screen/index.test.js +0 -2
- package/src/wizard/breadcrumbs-select.js +12 -4
- package/src/wizard/breadcrumbs-select.test.js +15 -1
- package/src/wizard/index.js +45 -14
- package/src/wizard/index.test.js +169 -3
- package/src/wizard/store/index.js +2 -0
package/src/action-card/index.js
CHANGED
|
@@ -5,8 +5,9 @@
|
|
|
5
5
|
/**
|
|
6
6
|
* WordPress dependencies
|
|
7
7
|
*/
|
|
8
|
-
import { Draggable, ExternalLink, ToggleControl } from '@wordpress/components';
|
|
9
|
-
import { useEffect, useState } from '@wordpress/element';
|
|
8
|
+
import { Draggable, ExternalLink, Notice, ToggleControl } from '@wordpress/components';
|
|
9
|
+
import { RawHTML, useEffect, useState } from '@wordpress/element';
|
|
10
|
+
import { decodeEntities } from '@wordpress/html-entities';
|
|
10
11
|
import { __ } from '@wordpress/i18n';
|
|
11
12
|
import { Icon, check, chevronDown, chevronUp, dragHandle } from '@wordpress/icons';
|
|
12
13
|
import { Badge } from '@wordpress/ui';
|
|
@@ -14,7 +15,7 @@ import { Badge } from '@wordpress/ui';
|
|
|
14
15
|
/**
|
|
15
16
|
* Internal dependencies
|
|
16
17
|
*/
|
|
17
|
-
import { Button, Card, Grid, Handoff,
|
|
18
|
+
import { Button, Card, Grid, Handoff, Waiting } from '../';
|
|
18
19
|
import { ActionCardProps } from './action-card.d.ts';
|
|
19
20
|
import './style.scss';
|
|
20
21
|
|
|
@@ -23,8 +24,76 @@ import './style.scss';
|
|
|
23
24
|
*/
|
|
24
25
|
import classnames from 'classnames';
|
|
25
26
|
|
|
27
|
+
const NOTIFICATION_LEVELS = [ 'error', 'info', 'success', 'warning' ];
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Collect a notification's text, including text nested inside React elements.
|
|
31
|
+
*
|
|
32
|
+
* Segments join with a space so that adjacent elements do not weld together: by the
|
|
33
|
+
* time speak() sees this string the markup that separated them is already gone.
|
|
34
|
+
*
|
|
35
|
+
* @param {*} value Notification content.
|
|
36
|
+
* @return {string} The collected text.
|
|
37
|
+
*/
|
|
38
|
+
const getNotificationText = value => {
|
|
39
|
+
if ( typeof value === 'string' ) {
|
|
40
|
+
return value;
|
|
41
|
+
}
|
|
42
|
+
if ( typeof value === 'number' ) {
|
|
43
|
+
return String( value );
|
|
44
|
+
}
|
|
45
|
+
if ( value instanceof Error ) {
|
|
46
|
+
return value.message;
|
|
47
|
+
}
|
|
48
|
+
if ( Array.isArray( value ) ) {
|
|
49
|
+
return value.map( getNotificationText ).join( ' ' );
|
|
50
|
+
}
|
|
51
|
+
return value?.props?.children === undefined ? '' : getNotificationText( value.props.children );
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Derive a plain-text announcement from a notification.
|
|
56
|
+
*
|
|
57
|
+
* Notice defaults spokenMessage to its children and runs renderToString over them
|
|
58
|
+
* during render, which corrupts the hook dispatcher when those children are
|
|
59
|
+
* components. A notification can be any element, so the announcement is always
|
|
60
|
+
* built from its text instead of letting that default apply. Tags in an HTML
|
|
61
|
+
* notification stay in, since speak() strips them itself. Entities are decoded so
|
|
62
|
+
* the announcement matches the text on screen, which getNotificationContent decodes
|
|
63
|
+
* for the same reason.
|
|
64
|
+
*
|
|
65
|
+
* @param {*} notification Notification content.
|
|
66
|
+
* @return {string} Message to announce.
|
|
67
|
+
*/
|
|
68
|
+
const getSpokenNotification = notification => decodeEntities( getNotificationText( notification ) ).replace( /\s+/g, ' ' ).trim();
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Decode a notification's own text for display.
|
|
72
|
+
*
|
|
73
|
+
* React does not decode entities in text children, and notification text is often
|
|
74
|
+
* server-sourced, so a message carrying `’` would otherwise show the entity
|
|
75
|
+
* and announce the character. The HTML branch is left alone: there the browser's
|
|
76
|
+
* parser decodes as it renders.
|
|
77
|
+
*
|
|
78
|
+
* @param {*} value Notification content.
|
|
79
|
+
* @return {*} The content, with its own strings decoded.
|
|
80
|
+
*/
|
|
81
|
+
const getNotificationContent = value => {
|
|
82
|
+
if ( value instanceof Error ) {
|
|
83
|
+
return decodeEntities( value.message );
|
|
84
|
+
}
|
|
85
|
+
if ( typeof value === 'string' ) {
|
|
86
|
+
return decodeEntities( value );
|
|
87
|
+
}
|
|
88
|
+
if ( Array.isArray( value ) ) {
|
|
89
|
+
return value.map( getNotificationContent );
|
|
90
|
+
}
|
|
91
|
+
return value;
|
|
92
|
+
};
|
|
93
|
+
|
|
26
94
|
/**
|
|
27
95
|
* ActionCard component
|
|
96
|
+
*
|
|
28
97
|
* @param {ActionCardProps} props Component props.
|
|
29
98
|
* @return {JSX.Element} ActionCard component.
|
|
30
99
|
*/
|
|
@@ -107,6 +176,7 @@ const ActionCard = ( {
|
|
|
107
176
|
}, [ collapse ] );
|
|
108
177
|
|
|
109
178
|
const hasChildren = notification || children;
|
|
179
|
+
const notificationContent = getNotificationContent( notification );
|
|
110
180
|
const classes = classnames(
|
|
111
181
|
'newspack-action-card',
|
|
112
182
|
simple && 'newspack-card--is-clickable',
|
|
@@ -243,12 +313,15 @@ const ActionCard = ( {
|
|
|
243
313
|
</Button>
|
|
244
314
|
) }
|
|
245
315
|
</div>
|
|
246
|
-
{ notification && (
|
|
316
|
+
{ notification && NOTIFICATION_LEVELS.includes( notificationLevel ) && (
|
|
247
317
|
<div className="newspack-action-card__notification newspack-action-card__region-children">
|
|
248
|
-
{
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
318
|
+
<Notice status={ notificationLevel } isDismissible={ false } spokenMessage={ getSpokenNotification( notification ) }>
|
|
319
|
+
{ notificationHTML && typeof notification === 'string' ? (
|
|
320
|
+
<RawHTML className="newspack-action-card__notification-html">{ notification }</RawHTML>
|
|
321
|
+
) : (
|
|
322
|
+
notificationContent
|
|
323
|
+
) }
|
|
324
|
+
</Notice>
|
|
252
325
|
</div>
|
|
253
326
|
) }
|
|
254
327
|
{ children && ( ( expandable && expanded ) || ! expandable ) ? (
|
|
@@ -3,11 +3,22 @@
|
|
|
3
3
|
*/
|
|
4
4
|
import { render, screen } from '@testing-library/react';
|
|
5
5
|
|
|
6
|
+
/**
|
|
7
|
+
* WordPress dependencies.
|
|
8
|
+
*/
|
|
9
|
+
import { speak } from '@wordpress/a11y';
|
|
10
|
+
import { useState } from '@wordpress/element';
|
|
11
|
+
|
|
6
12
|
/**
|
|
7
13
|
* Internal dependencies.
|
|
8
14
|
*/
|
|
9
15
|
import ActionCard from './index';
|
|
10
16
|
|
|
17
|
+
jest.mock( '@wordpress/a11y', () => ( {
|
|
18
|
+
...jest.requireActual( '@wordpress/a11y' ),
|
|
19
|
+
speak: jest.fn(),
|
|
20
|
+
} ) );
|
|
21
|
+
|
|
11
22
|
/**
|
|
12
23
|
* The library Badge styles its wrapper rather than its text, so a badge with no label
|
|
13
24
|
* paints a bare coloured pill. Callers build the `badges` array from data that can be
|
|
@@ -54,3 +65,151 @@ describe( 'ActionCard badges', () => {
|
|
|
54
65
|
expect( withEmptyArray.textContent ).not.toContain( '0' );
|
|
55
66
|
} );
|
|
56
67
|
} );
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* ActionCard derives the notice's spoken message rather than letting core default it to
|
|
71
|
+
* the children. Core runs `renderToString` over that default during render, which corrupts
|
|
72
|
+
* the hook dispatcher when the children are components — so the derivation is what keeps a
|
|
73
|
+
* notification carrying a `<Button>` from crashing the card on a later re-render.
|
|
74
|
+
*/
|
|
75
|
+
describe( 'ActionCard notifications', () => {
|
|
76
|
+
beforeEach( () => {
|
|
77
|
+
speak.mockClear();
|
|
78
|
+
} );
|
|
79
|
+
|
|
80
|
+
const spoken = () => speak.mock.calls.map( call => call[ 0 ] );
|
|
81
|
+
|
|
82
|
+
it( 'renders a notice for a recognised level', () => {
|
|
83
|
+
const { container } = render( <ActionCard title="Ad Manager" notification="Plugin cannot be installed" notificationLevel="error" /> );
|
|
84
|
+
|
|
85
|
+
expect( container.querySelector( '.components-notice.is-error' ) ).toBeInTheDocument();
|
|
86
|
+
} );
|
|
87
|
+
|
|
88
|
+
it( 'renders no notice for an unrecognised or absent level', () => {
|
|
89
|
+
const { container: unknown } = render( <ActionCard title="Ad Manager" notification="Something happened" notificationLevel="critical" /> );
|
|
90
|
+
expect( unknown.querySelector( '.components-notice' ) ).not.toBeInTheDocument();
|
|
91
|
+
|
|
92
|
+
const { container: absent } = render( <ActionCard title="Ad Manager" notification="Something happened" /> );
|
|
93
|
+
expect( absent.querySelector( '.components-notice' ) ).not.toBeInTheDocument();
|
|
94
|
+
} );
|
|
95
|
+
|
|
96
|
+
it( 'announces the text inside an element-only notification', () => {
|
|
97
|
+
render(
|
|
98
|
+
<ActionCard
|
|
99
|
+
title="Ad Manager"
|
|
100
|
+
notificationLevel="error"
|
|
101
|
+
notification={ [ <button key="connect">Click here to connect your account.</button> ] }
|
|
102
|
+
/>
|
|
103
|
+
);
|
|
104
|
+
|
|
105
|
+
expect( spoken() ).toContain( 'Click here to connect your account.' );
|
|
106
|
+
} );
|
|
107
|
+
|
|
108
|
+
it( 'announces strings and element text together', () => {
|
|
109
|
+
render(
|
|
110
|
+
<ActionCard
|
|
111
|
+
title="Ad Manager"
|
|
112
|
+
notificationLevel="success"
|
|
113
|
+
notification={ [
|
|
114
|
+
'Created custom targeting keys: ',
|
|
115
|
+
// eslint-disable-next-line react/jsx-indent
|
|
116
|
+
<a key="dashboard" href="https://example.org">
|
|
117
|
+
Visit your GAM dashboard
|
|
118
|
+
</a>,
|
|
119
|
+
] }
|
|
120
|
+
/>
|
|
121
|
+
);
|
|
122
|
+
|
|
123
|
+
expect( spoken() ).toContain( 'Created custom targeting keys: Visit your GAM dashboard' );
|
|
124
|
+
} );
|
|
125
|
+
|
|
126
|
+
it( 'announces an Error and renders its message rather than throwing', () => {
|
|
127
|
+
render( <ActionCard title="Ad Manager" notificationLevel="error" notification={ new Error( 'Network unreachable' ) } /> );
|
|
128
|
+
|
|
129
|
+
expect( screen.getByText( 'Network unreachable' ) ).toBeInTheDocument();
|
|
130
|
+
expect( spoken() ).toContain( 'Network unreachable' );
|
|
131
|
+
} );
|
|
132
|
+
|
|
133
|
+
it( 'renders HTML notifications in their own wrapper and hands speak() the tags to strip', () => {
|
|
134
|
+
const { container } = render(
|
|
135
|
+
<ActionCard title="Ad Manager" notificationLevel="error" notification={ '<p>Install failed.</p><p>Try again.</p>' } notificationHTML />
|
|
136
|
+
);
|
|
137
|
+
|
|
138
|
+
expect( container.querySelectorAll( '.newspack-action-card__notification-html > p' ) ).toHaveLength( 2 );
|
|
139
|
+
expect( spoken()[ 0 ] ).toContain( '<p>Install failed.</p>' );
|
|
140
|
+
} );
|
|
141
|
+
|
|
142
|
+
it( 'decodes entities in both the announcement and the visible text', () => {
|
|
143
|
+
render( <ActionCard title="Ad Manager" notificationLevel="error" notification={ 'Plugin “Foo” could not be activated' } /> );
|
|
144
|
+
|
|
145
|
+
expect( screen.getByText( 'Plugin “Foo” could not be activated' ) ).toBeInTheDocument();
|
|
146
|
+
expect( spoken()[ 0 ] ).toContain( '“Foo”' );
|
|
147
|
+
} );
|
|
148
|
+
|
|
149
|
+
it( 'decodes entities inside an array of notification parts', () => {
|
|
150
|
+
render(
|
|
151
|
+
<ActionCard title="Ad Manager" notificationLevel="error" notification={ [ 'Could not reach the publisher’s network.', ' ' ] } />
|
|
152
|
+
);
|
|
153
|
+
|
|
154
|
+
expect( screen.getByText( /Could not reach the publisher’s network\./ ) ).toBeInTheDocument();
|
|
155
|
+
expect( spoken()[ 0 ] ).toContain( 'publisher’s' );
|
|
156
|
+
} );
|
|
157
|
+
|
|
158
|
+
it( 'leaves entity decoding to the browser on the HTML branch', () => {
|
|
159
|
+
const { container } = render(
|
|
160
|
+
<ActionCard
|
|
161
|
+
title="Ad Manager"
|
|
162
|
+
notificationLevel="error"
|
|
163
|
+
notification={ 'Get it from <a href="https://example.org">the plugin’s site</a>' }
|
|
164
|
+
notificationHTML
|
|
165
|
+
/>
|
|
166
|
+
);
|
|
167
|
+
|
|
168
|
+
expect( container.querySelector( '.newspack-action-card__notification-html a' ).textContent ).toBe( 'the plugin’s site' );
|
|
169
|
+
expect( spoken()[ 0 ] ).toContain( 'plugin’s site' );
|
|
170
|
+
} );
|
|
171
|
+
|
|
172
|
+
it( 'renders a non-string notification as children even when notificationHTML is set', () => {
|
|
173
|
+
const { container } = render(
|
|
174
|
+
<ActionCard title="Ad Manager" notificationLevel="error" notification={ <span>Network unreachable</span> } notificationHTML />
|
|
175
|
+
);
|
|
176
|
+
|
|
177
|
+
expect( screen.getByText( 'Network unreachable' ) ).toBeInTheDocument();
|
|
178
|
+
expect( container.querySelector( '.newspack-action-card__notification-html' ) ).not.toBeInTheDocument();
|
|
179
|
+
} );
|
|
180
|
+
|
|
181
|
+
it( 'keeps adjacent element texts apart in the announcement', () => {
|
|
182
|
+
render(
|
|
183
|
+
<ActionCard
|
|
184
|
+
title="Ad Manager"
|
|
185
|
+
notificationLevel="error"
|
|
186
|
+
notification={ [
|
|
187
|
+
// eslint-disable-next-line react/jsx-indent
|
|
188
|
+
<a key="dashboard" href="https://example.org">
|
|
189
|
+
Visit your GAM dashboard
|
|
190
|
+
</a>,
|
|
191
|
+
<button key="connect" type="button">
|
|
192
|
+
Click here to connect your account.
|
|
193
|
+
</button>,
|
|
194
|
+
] }
|
|
195
|
+
/>
|
|
196
|
+
);
|
|
197
|
+
|
|
198
|
+
expect( spoken()[ 0 ] ).toBe( 'Visit your GAM dashboard Click here to connect your account.' );
|
|
199
|
+
} );
|
|
200
|
+
|
|
201
|
+
it( 'survives a re-render that changes how many hooked components the notification holds', () => {
|
|
202
|
+
const HookedAction = ( { children } ) => {
|
|
203
|
+
const [ label ] = useState( children );
|
|
204
|
+
return <button type="button">{ label }</button>;
|
|
205
|
+
};
|
|
206
|
+
const card = notification => <ActionCard title="Ad Manager" notificationLevel="error" notification={ notification } />;
|
|
207
|
+
|
|
208
|
+
const { rerender, container } = render(
|
|
209
|
+
card( [ <HookedAction key="retry">Retry</HookedAction>, <HookedAction key="docs">Documentation</HookedAction> ] )
|
|
210
|
+
);
|
|
211
|
+
|
|
212
|
+
expect( () => rerender( card( [ <HookedAction key="retry">Retry</HookedAction> ] ) ) ).not.toThrow();
|
|
213
|
+
expect( container.querySelector( '.components-notice.is-error' ) ).toBeInTheDocument();
|
|
214
|
+
} );
|
|
215
|
+
} );
|
|
@@ -339,18 +339,12 @@
|
|
|
339
339
|
|
|
340
340
|
/* Notifications */
|
|
341
341
|
.newspack-action-card__notification {
|
|
342
|
-
.
|
|
342
|
+
.components-notice {
|
|
343
343
|
margin: 0;
|
|
344
|
-
|
|
345
|
-
&__heading {
|
|
346
|
-
margin-top: 0;
|
|
347
|
-
}
|
|
348
344
|
}
|
|
349
345
|
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
margin-top: 32px;
|
|
353
|
-
}
|
|
346
|
+
.newspack-action-card__notification-html > * {
|
|
347
|
+
margin: 0;
|
|
354
348
|
}
|
|
355
349
|
}
|
|
356
350
|
|
|
@@ -11,13 +11,15 @@ A card component for presenting a named feature or setting with a predictable, s
|
|
|
11
11
|
|
|
12
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
13
|
|
|
14
|
+
When enabling leaves nothing to configure, the primary button goes away while it still has focus; the card moves that focus to the "More" menu rather than letting it fall to the page.
|
|
15
|
+
|
|
14
16
|
## States
|
|
15
17
|
|
|
16
18
|
| State | Condition | Button | Dropdown | Badge |
|
|
17
19
|
|---|---|---|---|---|
|
|
18
20
|
| **Unmet requirements** | `requirements` is set | "Enable" — blocked but still focusable, and described by the badge (clickable if `requirementsActionable`) | Shown if `enabled` and `requirementsActionable` (and `moreControls` provided); otherwise hidden | High-intent badge with `requirements` text |
|
|
19
21
|
| **Disabled** | `!enabled`, no requirements | "Enable" | Hidden | None |
|
|
20
|
-
| **Enabled** | `enabled`, no requirements | "Configure" | Shown if `moreControls` provided | Stable-intent badge ("Enabled") |
|
|
22
|
+
| **Enabled** | `enabled`, no requirements | "Configure", or none without `onConfigure` | Shown if `moreControls` provided | Stable-intent badge ("Enabled") |
|
|
21
23
|
|
|
22
24
|
When `requirements` is set the title drops to the muted text colour. The description already uses that colour in every state, so the unmet-requirements state is signalled by the title colour plus the high-intent badge.
|
|
23
25
|
|
|
@@ -75,9 +77,9 @@ import { __ } from '@wordpress/i18n';
|
|
|
75
77
|
|
|
76
78
|
## With a custom icon
|
|
77
79
|
|
|
78
|
-
`icon` takes either a descriptor object or a ready React element. A descriptor gets the standard treatment: pass `node` for the icon element, `fill` for the SVG colour, `backgroundColor` for a container background
|
|
80
|
+
`icon` takes either a descriptor object or a ready React element. A descriptor gets the standard treatment: pass `node` for the icon element, `fill` for the SVG colour, and `backgroundColor` for a container background. A ready element renders exactly as given, with no container or background, which is the escape hatch for an icon that already carries its own chrome.
|
|
79
81
|
|
|
80
|
-
A descriptor's container is always **40 × 40 px** with the SVG at **24 × 24 px**. Setting `backgroundColor`
|
|
82
|
+
A descriptor's container is always **40 × 40 px** with the SVG at **24 × 24 px**. Setting `backgroundColor` makes the container a circle.
|
|
81
83
|
|
|
82
84
|
`fill` sets the container's `color`, which the SVG picks up through `fill: currentcolor`. That only recolours single-colour icons that inherit their fill, such as those from `@wordpress/icons`. A vendor's own mark carries `fill` on its paths and keeps its colours, so pair it with `backgroundColor` rather than trying to tint it.
|
|
83
85
|
|
|
@@ -101,9 +103,9 @@ import colors from 'newspack-colors';
|
|
|
101
103
|
|
|
102
104
|
// A vendor mark on its own brand background, keeping the mark's colours
|
|
103
105
|
<CardFeature
|
|
104
|
-
title=
|
|
106
|
+
title="Mailchimp"
|
|
105
107
|
description={ __( 'Sync reader activity with your Mailchimp audience.', 'newspack-plugin' ) }
|
|
106
|
-
icon={ { node: <MailchimpMark />, backgroundColor: '#ffe01b'
|
|
108
|
+
icon={ { node: <MailchimpMark />, backgroundColor: '#ffe01b' } }
|
|
107
109
|
enabled={ isEnabled }
|
|
108
110
|
onEnable={ handleEnable }
|
|
109
111
|
onConfigure={ handleConfigure }
|
|
@@ -112,7 +114,7 @@ import colors from 'newspack-colors';
|
|
|
112
114
|
|
|
113
115
|
// A ready element, rendered as-is
|
|
114
116
|
<CardFeature
|
|
115
|
-
title=
|
|
117
|
+
title="Mailchimp"
|
|
116
118
|
description={ __( 'Sync reader activity with your Mailchimp audience.', 'newspack-plugin' ) }
|
|
117
119
|
icon={ <IntegrationIcon provider="mailchimp" /> }
|
|
118
120
|
enabled={ isEnabled }
|
|
@@ -203,7 +205,7 @@ The card is built on `Card.Root`, `Card.Header` and `Card.Content` from `@wordpr
|
|
|
203
205
|
| `enableLabel` | `string` | `"Enable"` | Label for the primary button in its "Enable" states: not enabled, or enabled with an unmet requirement |
|
|
204
206
|
| `configureLabel` | `string` | `"Configure"` | Label for the primary button in its "Configure" state: enabled, with no unmet requirement |
|
|
205
207
|
| `onEnable` | `() => void` | — | Called when the primary button is clicked while it reads "Enable". That covers the not-enabled case and the enabled-with-unmet-requirements case, where the feature is on but the requirement is what the button acts on |
|
|
206
|
-
| `onConfigure` | `() => void` | — | Called when the primary button is clicked while it reads "Configure", which is the enabled state with no unmet requirements |
|
|
208
|
+
| `onConfigure` | `() => void` | — | Called when the primary button is clicked while it reads "Configure", which is the enabled state with no unmet requirements. Omit it for a feature with nothing to configure, and that state shows no primary button |
|
|
207
209
|
| `moreControls` | `MoreControl[]` | — | Items for the "More" dropdown. Shown when `enabled` and either there are no `requirements` or `requirementsActionable` is set |
|
|
208
210
|
| `badge` | `{ label?: string; intent?: BadgeIntent }` | `{ label: "Enabled", intent: "stable" }` | Badge shown when enabled. Ignored while `requirements` is set, which takes the badge |
|
|
209
211
|
| `busy` | `boolean` | `false` | Shows the primary button as busy and blocks it while an action is in flight |
|
|
@@ -215,10 +217,7 @@ The card is built on `Card.Root`, `Card.Header` and `Card.Content` from `@wordpr
|
|
|
215
217
|
type CardFeatureIcon = {
|
|
216
218
|
node: React.ReactNode; // The icon element to render
|
|
217
219
|
fill?: string; // SVG fill colour (applied via currentColor)
|
|
218
|
-
backgroundColor?: string; // Background colour of the 40×40 container
|
|
219
|
-
radius?: 'small' | 'full'; // 'small' = 2px ($radius-small), 'full' = 50% ($radius-round)
|
|
220
|
-
// Defaults to 'small' whenever backgroundColor is set,
|
|
221
|
-
// and has nothing to round without one.
|
|
220
|
+
backgroundColor?: string; // Background colour of the 40×40 container, always a circle
|
|
222
221
|
};
|
|
223
222
|
```
|
|
224
223
|
|
|
@@ -61,7 +61,7 @@ describe( 'CardFeature', () => {
|
|
|
61
61
|
} );
|
|
62
62
|
|
|
63
63
|
it( 'names the feature in the configure state too', () => {
|
|
64
|
-
render( <CardFeature title="Content Gifting" enabled /> );
|
|
64
|
+
render( <CardFeature title="Content Gifting" enabled onConfigure={ () => {} } /> );
|
|
65
65
|
expect( screen.getByRole( 'button', { name: 'Configure Content Gifting' } ) ).toBeInTheDocument();
|
|
66
66
|
} );
|
|
67
67
|
|
|
@@ -108,6 +108,43 @@ describe( 'CardFeature', () => {
|
|
|
108
108
|
expect( onEnable ).not.toHaveBeenCalled();
|
|
109
109
|
} );
|
|
110
110
|
|
|
111
|
+
// A button reading Configure with nothing behind it would click to nowhere.
|
|
112
|
+
it( 'drops the primary button once enabled when there is nothing to configure', () => {
|
|
113
|
+
render(
|
|
114
|
+
<CardFeature title="Content gifting" enabled onEnable={ jest.fn() } moreControls={ [ { title: 'Disable', onClick: jest.fn() } ] } />
|
|
115
|
+
);
|
|
116
|
+
expect( screen.queryByRole( 'button', { name: /Configure/ } ) ).toBeNull();
|
|
117
|
+
expect( moreMenu() ).toBeInTheDocument();
|
|
118
|
+
} );
|
|
119
|
+
|
|
120
|
+
// Enabling removes the button that was just used; without a hand-off,
|
|
121
|
+
// keyboard focus falls to the page.
|
|
122
|
+
it( 'hands focus to the More menu when enabling removes the focused button', () => {
|
|
123
|
+
const props = { title: 'Content gifting', onEnable: jest.fn(), moreControls: [ { title: 'Disable', onClick: jest.fn() } ] };
|
|
124
|
+
const { rerender } = render( <CardFeature { ...props } /> );
|
|
125
|
+
primaryButton().focus();
|
|
126
|
+
rerender( <CardFeature { ...props } enabled /> );
|
|
127
|
+
expect( moreMenu() ).toHaveFocus();
|
|
128
|
+
} );
|
|
129
|
+
|
|
130
|
+
it( 'leaves focus alone when the removed button did not have it', () => {
|
|
131
|
+
const props = { title: 'Content gifting', onEnable: jest.fn(), moreControls: [ { title: 'Disable', onClick: jest.fn() } ] };
|
|
132
|
+
const { rerender } = render(
|
|
133
|
+
<>
|
|
134
|
+
<CardFeature { ...props } />
|
|
135
|
+
<button>Elsewhere</button>
|
|
136
|
+
</>
|
|
137
|
+
);
|
|
138
|
+
screen.getByRole( 'button', { name: 'Elsewhere' } ).focus();
|
|
139
|
+
rerender(
|
|
140
|
+
<>
|
|
141
|
+
<CardFeature { ...props } enabled />
|
|
142
|
+
<button>Elsewhere</button>
|
|
143
|
+
</>
|
|
144
|
+
);
|
|
145
|
+
expect( screen.getByRole( 'button', { name: 'Elsewhere' } ) ).toHaveFocus();
|
|
146
|
+
} );
|
|
147
|
+
|
|
111
148
|
it( 'still routes to onEnable when enabled with an unmet requirement, since the button reads Enable', () => {
|
|
112
149
|
const onEnable = jest.fn();
|
|
113
150
|
const onConfigure = jest.fn();
|
|
@@ -162,7 +199,7 @@ describe( 'CardFeature', () => {
|
|
|
162
199
|
} );
|
|
163
200
|
|
|
164
201
|
it( 'leaves the enabled badge unlinked, since it explains nothing about the button', () => {
|
|
165
|
-
render( <CardFeature title="Content gifting" enabled /> );
|
|
202
|
+
render( <CardFeature title="Content gifting" enabled onConfigure={ () => {} } /> );
|
|
166
203
|
expect( primaryButton() ).not.toHaveAttribute( 'aria-describedby' );
|
|
167
204
|
} );
|
|
168
205
|
|
|
@@ -180,7 +217,7 @@ describe( 'CardFeature', () => {
|
|
|
180
217
|
it( 'accepts custom labels for both states', () => {
|
|
181
218
|
const { rerender } = render( <CardFeature title="Apple News" enableLabel="Connect" configureLabel="Manage connection" /> );
|
|
182
219
|
expect( screen.getByRole( 'button', { name: 'Connect Apple News' } ) ).toBeInTheDocument();
|
|
183
|
-
rerender( <CardFeature title="Apple News" enabled enableLabel="Connect" configureLabel="Manage connection" /> );
|
|
220
|
+
rerender( <CardFeature title="Apple News" enabled enableLabel="Connect" configureLabel="Manage connection" onConfigure={ () => {} } /> );
|
|
184
221
|
expect( screen.getByRole( 'button', { name: 'Manage connection Apple News' } ) ).toBeInTheDocument();
|
|
185
222
|
} );
|
|
186
223
|
} );
|
|
@@ -234,16 +271,16 @@ describe( 'CardFeature', () => {
|
|
|
234
271
|
expect( container.querySelector( '.newspack-card-feature__icon' ) ).toBeNull();
|
|
235
272
|
} );
|
|
236
273
|
|
|
237
|
-
it( 'applies the descriptor colours inline and rounds
|
|
274
|
+
it( 'applies the descriptor colours inline and rounds a backed icon', () => {
|
|
238
275
|
const { container } = render(
|
|
239
276
|
<CardFeature
|
|
240
277
|
title="Content gifting"
|
|
241
|
-
icon={ { node: <span data-testid="descriptor-icon" />, fill: '#003da5', backgroundColor: '#dfe7f4'
|
|
278
|
+
icon={ { node: <span data-testid="descriptor-icon" />, fill: '#003da5', backgroundColor: '#dfe7f4' } }
|
|
242
279
|
/>
|
|
243
280
|
);
|
|
244
281
|
const iconContainer = container.querySelector( '.newspack-card-feature__icon' );
|
|
245
282
|
expect( screen.getByTestId( 'descriptor-icon' ) ).toBeInTheDocument();
|
|
246
|
-
expect( iconContainer ).toHaveClass( 'newspack-card-feature__icon--
|
|
283
|
+
expect( iconContainer ).toHaveClass( 'newspack-card-feature__icon--has-background' );
|
|
247
284
|
expect( iconContainer ).toHaveStyle( { backgroundColor: '#dfe7f4', color: '#003da5' } );
|
|
248
285
|
} );
|
|
249
286
|
|
|
@@ -252,17 +289,10 @@ describe( 'CardFeature', () => {
|
|
|
252
289
|
expect( container.querySelector( '.newspack-card-feature__icon' ) ).toHaveAttribute( 'aria-hidden', 'true' );
|
|
253
290
|
} );
|
|
254
291
|
|
|
255
|
-
it( '
|
|
256
|
-
const { container } = render( <CardFeature title="Content gifting" icon={ { node: <span />, backgroundColor: '#dfe7f4' } } /> );
|
|
257
|
-
const iconContainer = container.querySelector( '.newspack-card-feature__icon' );
|
|
258
|
-
expect( iconContainer ).toHaveClass( 'newspack-card-feature__icon--radius-small' );
|
|
259
|
-
expect( iconContainer ).not.toHaveClass( 'newspack-card-feature__icon--radius-full' );
|
|
260
|
-
} );
|
|
261
|
-
|
|
262
|
-
it( 'leaves an unbacked descriptor icon without a radius class', () => {
|
|
292
|
+
it( 'leaves an unbacked descriptor icon without the background class', () => {
|
|
263
293
|
const { container } = render( <CardFeature title="Content gifting" icon={ { node: <span />, fill: '#003da5' } } /> );
|
|
264
294
|
const iconContainer = container.querySelector( '.newspack-card-feature__icon' );
|
|
265
|
-
expect( iconContainer ).not.toHaveClass( 'newspack-card-feature__icon--
|
|
295
|
+
expect( iconContainer ).not.toHaveClass( 'newspack-card-feature__icon--has-background' );
|
|
266
296
|
} );
|
|
267
297
|
} );
|
|
268
298
|
|
|
@@ -7,7 +7,7 @@ import classnames from 'classnames';
|
|
|
7
7
|
* WordPress dependencies
|
|
8
8
|
*/
|
|
9
9
|
import { __, _x, sprintf } from '@wordpress/i18n';
|
|
10
|
-
import { createElement, isValidElement } from '@wordpress/element';
|
|
10
|
+
import { createElement, isValidElement, useCallback, useEffect, useRef } from '@wordpress/element';
|
|
11
11
|
import { useInstanceId } from '@wordpress/compose';
|
|
12
12
|
import { DropdownMenu } from '@wordpress/components';
|
|
13
13
|
import { moreVertical } from '@wordpress/icons';
|
|
@@ -25,14 +25,8 @@ type CardFeatureIcon = {
|
|
|
25
25
|
node: React.ReactNode;
|
|
26
26
|
/** SVG fill colour, applied via currentColor. */
|
|
27
27
|
fill?: string;
|
|
28
|
-
/** Background colour for the icon container. */
|
|
28
|
+
/** Background colour for the icon container, which is always a circle. */
|
|
29
29
|
backgroundColor?: string;
|
|
30
|
-
/**
|
|
31
|
-
* Border-radius of the icon container.
|
|
32
|
-
* 'small' uses $radius-small (2px), 'full' uses $radius-round (50%).
|
|
33
|
-
* Only relevant when backgroundColor is set, where it defaults to 'small'.
|
|
34
|
-
*/
|
|
35
|
-
radius?: 'small' | 'full';
|
|
36
30
|
};
|
|
37
31
|
|
|
38
32
|
type MoreControl = {
|
|
@@ -77,7 +71,7 @@ type CardFeatureProps = {
|
|
|
77
71
|
* where the requirement rather than the feature is what the button acts on.
|
|
78
72
|
*/
|
|
79
73
|
onEnable?: () => void;
|
|
80
|
-
/** Called when the primary button is clicked while it reads "Configure": enabled, with no unmet requirements. */
|
|
74
|
+
/** Called when the primary button is clicked while it reads "Configure": enabled, with no unmet requirements. Omit it for a feature with nothing to configure, and that state shows no primary button. */
|
|
81
75
|
onConfigure?: () => void;
|
|
82
76
|
/** Controls rendered inside the "More" dropdown, shown when enabled — including the unmet-requirements state when `requirementsActionable`. */
|
|
83
77
|
moreControls?: MoreControl[];
|
|
@@ -134,6 +128,29 @@ const CardFeature = ( {
|
|
|
134
128
|
title
|
|
135
129
|
);
|
|
136
130
|
const showMoreControls = enabled && !! moreControls?.length && ( ! requirements || requirementsActionable );
|
|
131
|
+
// A Configure button with nothing behind it would click to nowhere.
|
|
132
|
+
const showPrimaryButton = ! isConfigureState || !! onConfigure;
|
|
133
|
+
|
|
134
|
+
// Enabling a feature with nothing to configure removes the button that was
|
|
135
|
+
// just used, so its focus goes to the More menu instead of the page. React
|
|
136
|
+
// detaches a ref before removing the node, which is the last point the
|
|
137
|
+
// button can still be read as focused.
|
|
138
|
+
const actionsRef = useRef< HTMLDivElement >( null );
|
|
139
|
+
const primaryButtonNode = useRef< HTMLElement | null >( null );
|
|
140
|
+
const primaryButtonLeftWithFocus = useRef( false );
|
|
141
|
+
const primaryButtonRef = useCallback( ( node: HTMLElement | null ) => {
|
|
142
|
+
if ( ! node && primaryButtonNode.current ) {
|
|
143
|
+
primaryButtonLeftWithFocus.current = primaryButtonNode.current === primaryButtonNode.current.ownerDocument.activeElement;
|
|
144
|
+
}
|
|
145
|
+
primaryButtonNode.current = node;
|
|
146
|
+
}, [] );
|
|
147
|
+
useEffect( () => {
|
|
148
|
+
if ( showPrimaryButton || ! primaryButtonLeftWithFocus.current ) {
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
151
|
+
primaryButtonLeftWithFocus.current = false;
|
|
152
|
+
actionsRef.current?.querySelector< HTMLElement >( '.components-dropdown-menu__toggle' )?.focus();
|
|
153
|
+
}, [ showPrimaryButton ] );
|
|
137
154
|
|
|
138
155
|
const handleButtonClick = () => {
|
|
139
156
|
if ( isConfigureState ) {
|
|
@@ -146,8 +163,7 @@ const CardFeature = ( {
|
|
|
146
163
|
const iconDescriptor = icon && ! isValidElement( icon ) ? ( icon as CardFeatureIcon ) : null;
|
|
147
164
|
const iconClasses = iconDescriptor
|
|
148
165
|
? classnames( 'newspack-card-feature__icon', {
|
|
149
|
-
'newspack-card-feature__icon--
|
|
150
|
-
'newspack-card-feature__icon--radius-full': iconDescriptor.radius === 'full',
|
|
166
|
+
'newspack-card-feature__icon--has-background': !! iconDescriptor.backgroundColor,
|
|
151
167
|
} )
|
|
152
168
|
: undefined;
|
|
153
169
|
|
|
@@ -183,19 +199,22 @@ const CardFeature = ( {
|
|
|
183
199
|
</Card.Header>
|
|
184
200
|
<Card.Content className="newspack-card-feature__actions">
|
|
185
201
|
<Stack direction="row" align="center" justify="space-between" gap="sm" wrap="wrap">
|
|
186
|
-
<Stack direction="row" align="center" gap="sm">
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
202
|
+
<Stack ref={ actionsRef } direction="row" align="center" gap="sm">
|
|
203
|
+
{ showPrimaryButton && (
|
|
204
|
+
<Button
|
|
205
|
+
ref={ primaryButtonRef }
|
|
206
|
+
variant={ isConfigureState ? 'tertiary' : 'secondary' }
|
|
207
|
+
accessibleWhenDisabled
|
|
208
|
+
aria-describedby={ describedById }
|
|
209
|
+
aria-label={ buttonAccessibleLabel }
|
|
210
|
+
disabled={ ( isMuted && ! requirementsActionable ) || busy }
|
|
211
|
+
isBusy={ busy }
|
|
212
|
+
onClick={ handleButtonClick }
|
|
213
|
+
size="compact"
|
|
214
|
+
>
|
|
215
|
+
{ buttonLabel }
|
|
216
|
+
</Button>
|
|
217
|
+
) }
|
|
199
218
|
{ showMoreControls && (
|
|
200
219
|
<DropdownMenu
|
|
201
220
|
icon={ moreVertical }
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Debug Badge
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* WordPress dependencies.
|
|
7
|
+
*/
|
|
8
|
+
import { __ } from '@wordpress/i18n';
|
|
9
|
+
import { Icon, bug } from '@wordpress/icons';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Internal dependencies.
|
|
13
|
+
*/
|
|
14
|
+
import './style.scss';
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Debug Badge component.
|
|
18
|
+
*
|
|
19
|
+
* Gates itself on `window.newspack_aux_data.is_debug_mode`, so consumers render
|
|
20
|
+
* it unconditionally and it stays absent outside debug mode.
|
|
21
|
+
*
|
|
22
|
+
* @return {JSX.Element|null} Debug Badge component, or nothing outside debug mode.
|
|
23
|
+
*/
|
|
24
|
+
const DebugBadge = () => {
|
|
25
|
+
if ( ! window.newspack_aux_data?.is_debug_mode ) {
|
|
26
|
+
return null;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
return (
|
|
30
|
+
<div className="newspack-debug-badge" role="img" aria-label={ __( 'Debug mode', 'newspack-plugin' ) }>
|
|
31
|
+
<Icon icon={ bug } />
|
|
32
|
+
</div>
|
|
33
|
+
);
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
export default DebugBadge;
|