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.
Files changed (103) hide show
  1. package/CHANGELOG.md +10 -0
  2. package/DEVELOPMENT.md +18 -17
  3. package/dist/cjs/action-card/index.js +85 -18
  4. package/dist/cjs/action-card/index.test.js +192 -5
  5. package/dist/cjs/action-card/style.scss +3 -9
  6. package/dist/cjs/card-feature/index.js +28 -3
  7. package/dist/cjs/card-feature/index.test.js +100 -45
  8. package/dist/cjs/card-feature/style.scss +1 -5
  9. package/dist/cjs/debug-badge/index.js +44 -0
  10. package/dist/cjs/debug-badge/index.test.js +50 -0
  11. package/dist/cjs/debug-badge/style.scss +26 -0
  12. package/dist/cjs/empty-state/index.test.js +37 -24
  13. package/dist/cjs/empty-state/root.js +2 -1
  14. package/dist/cjs/footer/index.js +52 -16
  15. package/dist/cjs/footer/index.test.js +208 -0
  16. package/dist/cjs/footer/style.scss +17 -1
  17. package/dist/cjs/index.js +7 -0
  18. package/dist/cjs/integration-icon/index.js +4 -4
  19. package/dist/cjs/integration-icon/style.scss +18 -2
  20. package/dist/cjs/integration-icons/active-campaign.js +4 -7
  21. package/dist/cjs/integration-icons/beehiiv.js +19 -0
  22. package/dist/cjs/integration-icons/gravity-forms.js +19 -0
  23. package/dist/cjs/integration-icons/index.js +26 -6
  24. package/dist/cjs/integration-icons/salesforce.js +5 -3
  25. package/dist/cjs/notice/index.js +1 -4
  26. package/dist/cjs/notice/style.scss +0 -19
  27. package/dist/cjs/plugin-toggle/index.js +1 -1
  28. package/dist/cjs/with-wizard-screen/index.js +1 -3
  29. package/dist/cjs/with-wizard-screen/index.test.js +0 -3
  30. package/dist/cjs/wizard/breadcrumbs-select.js +17 -4
  31. package/dist/cjs/wizard/breadcrumbs-select.test.js +17 -0
  32. package/dist/cjs/wizard/index.js +50 -13
  33. package/dist/cjs/wizard/index.test.js +304 -5
  34. package/dist/cjs/wizard/store/index.js +2 -0
  35. package/dist/esm/action-card/index.js +86 -21
  36. package/dist/esm/action-card/index.test.js +192 -1
  37. package/dist/esm/action-card/style.scss +3 -9
  38. package/dist/esm/card-feature/index.js +29 -4
  39. package/dist/esm/card-feature/index.test.js +100 -45
  40. package/dist/esm/card-feature/style.scss +1 -5
  41. package/dist/esm/debug-badge/index.js +39 -0
  42. package/dist/esm/debug-badge/index.test.js +46 -0
  43. package/dist/esm/debug-badge/style.scss +26 -0
  44. package/dist/esm/empty-state/index.test.js +37 -24
  45. package/dist/esm/empty-state/root.js +2 -1
  46. package/dist/esm/footer/index.js +52 -17
  47. package/dist/esm/footer/index.test.js +210 -0
  48. package/dist/esm/footer/style.scss +17 -1
  49. package/dist/esm/index.js +1 -0
  50. package/dist/esm/integration-icon/index.js +5 -5
  51. package/dist/esm/integration-icon/style.scss +18 -2
  52. package/dist/esm/integration-icons/active-campaign.js +5 -8
  53. package/dist/esm/integration-icons/beehiiv.js +13 -0
  54. package/dist/esm/integration-icons/gravity-forms.js +13 -0
  55. package/dist/esm/integration-icons/index.js +15 -4
  56. package/dist/esm/integration-icons/salesforce.js +5 -3
  57. package/dist/esm/notice/index.js +2 -5
  58. package/dist/esm/notice/style.scss +0 -19
  59. package/dist/esm/plugin-toggle/index.js +1 -1
  60. package/dist/esm/with-wizard-screen/index.js +2 -4
  61. package/dist/esm/with-wizard-screen/index.test.js +0 -3
  62. package/dist/esm/wizard/breadcrumbs-select.js +16 -3
  63. package/dist/esm/wizard/breadcrumbs-select.test.js +18 -1
  64. package/dist/esm/wizard/index.js +53 -16
  65. package/dist/esm/wizard/index.test.js +304 -5
  66. package/dist/esm/wizard/store/index.js +2 -0
  67. package/package.json +1 -1
  68. package/src/action-card/action-card.d.ts +3 -2
  69. package/src/action-card/index.js +81 -8
  70. package/src/action-card/index.test.js +159 -0
  71. package/src/action-card/style.scss +3 -9
  72. package/src/card-feature/README.md +10 -11
  73. package/src/card-feature/index.test.js +45 -15
  74. package/src/card-feature/index.tsx +43 -24
  75. package/src/card-feature/style.scss +1 -5
  76. package/src/debug-badge/index.js +36 -0
  77. package/src/debug-badge/index.test.js +39 -0
  78. package/src/debug-badge/style.scss +26 -0
  79. package/src/empty-state/README.md +11 -10
  80. package/src/empty-state/index.test.js +10 -0
  81. package/src/empty-state/root.tsx +2 -1
  82. package/src/empty-state/types.ts +1 -1
  83. package/src/footer/index.js +47 -2
  84. package/src/footer/index.test.js +114 -0
  85. package/src/footer/style.scss +17 -1
  86. package/src/index.js +1 -0
  87. package/src/integration-icon/index.tsx +5 -5
  88. package/src/integration-icon/style.scss +18 -2
  89. package/src/integration-icons/active-campaign.js +1 -5
  90. package/src/integration-icons/beehiiv.js +10 -0
  91. package/src/integration-icons/gravity-forms.js +10 -0
  92. package/src/integration-icons/index.js +14 -3
  93. package/src/integration-icons/salesforce.js +3 -3
  94. package/src/notice/index.js +2 -17
  95. package/src/notice/style.scss +0 -19
  96. package/src/plugin-toggle/index.js +1 -1
  97. package/src/with-wizard-screen/index.js +2 -2
  98. package/src/with-wizard-screen/index.test.js +0 -2
  99. package/src/wizard/breadcrumbs-select.js +12 -4
  100. package/src/wizard/breadcrumbs-select.test.js +15 -1
  101. package/src/wizard/index.js +45 -14
  102. package/src/wizard/index.test.js +169 -3
  103. package/src/wizard/store/index.js +2 -0
@@ -9,8 +9,6 @@ import { HashRouter } from 'react-router-dom';
9
9
  */
10
10
  import withWizardScreen from './';
11
11
 
12
- global.newspack_aux_data = { is_debug_mode: false };
13
-
14
12
  describe( 'withWizardScreen', () => {
15
13
  it( 'falls back to a single headerText breadcrumb when no breadcrumbItems are provided', () => {
16
14
  const WrappedComponent = () => <div>Body content</div>;
@@ -11,12 +11,20 @@ import { matchesRoute } from '../route-match';
11
11
  * @param {string} pathname Current router pathname.
12
12
  * @return {Array} Breadcrumb items `{ label, url? }`.
13
13
  */
14
- export const activeBreadcrumbs = ( sections = [], pathname ) => {
14
+ export const activeBreadcrumbs = ( sections = [], pathname ) => activeSection( sections, pathname )?.breadcrumbs || [];
15
+
16
+ /**
17
+ * Select the section matching the current route, falling back to the first.
18
+ *
19
+ * @param {Array} sections Wizard sections.
20
+ * @param {string} pathname Current router pathname.
21
+ * @return {Object|undefined} The active section, if any.
22
+ */
23
+ export const activeSection = ( sections = [], pathname ) => {
15
24
  if ( ! sections?.length ) {
16
- return [];
25
+ return undefined;
17
26
  }
18
- const match = sections.find( section => matchesRoute( section, pathname ) ) || sections[ 0 ];
19
- return match.breadcrumbs || [];
27
+ return sections.find( section => matchesRoute( section, pathname ) ) || sections[ 0 ];
20
28
  };
21
29
 
22
30
  /**
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * Internal dependencies.
3
3
  */
4
- import { activeBreadcrumbs, appendSectionName } from './breadcrumbs-select';
4
+ import { activeBreadcrumbs, activeSection, appendSectionName } from './breadcrumbs-select';
5
5
 
6
6
  const SECTIONS = [
7
7
  { path: '/', breadcrumbs: [ { label: 'Audience Management' }, { label: 'Configuration' } ] },
@@ -122,3 +122,17 @@ describe( 'appendSectionName', () => {
122
122
  expect( trail ).toEqual( [ { label: 'Access Control' } ] );
123
123
  } );
124
124
  } );
125
+
126
+ describe( 'activeSection', () => {
127
+ const sections = [ { path: '/settings', exact: true, subHeaderText: 'Intro' }, { path: '/settings/:integrationId' } ];
128
+
129
+ it( 'returns the section matching the route', () => {
130
+ expect( activeSection( sections, '/settings/esp' ) ).toBe( sections[ 1 ] );
131
+ expect( activeSection( sections, '/settings' ).subHeaderText ).toBe( 'Intro' );
132
+ } );
133
+
134
+ it( 'falls back to the first section, or nothing without sections', () => {
135
+ expect( activeSection( sections, '/unknown' ) ).toBe( sections[ 0 ] );
136
+ expect( activeSection( [], '/settings' ) ).toBeUndefined();
137
+ } );
138
+ } );
@@ -6,27 +6,26 @@ import classnames from 'classnames';
6
6
  /**
7
7
  * WordPress dependencies.
8
8
  */
9
- // Notice is aliased: `Notice` below is Newspack's own, which this file also uses.
10
9
  import {
11
10
  DropdownMenu,
12
11
  MenuGroup,
13
12
  MenuItem,
14
- Notice as CoreNotice,
13
+ Notice,
15
14
  SlotFillProvider,
16
15
  createSlotFill,
17
16
  // eslint-disable-next-line @wordpress/no-unsafe-wp-apis
18
17
  __experimentalVStack as VStack,
19
18
  } from '@wordpress/components';
20
19
  import { useDispatch, useSelect } from '@wordpress/data';
21
- import { cloneElement, createInterpolateElement, isValidElement, useEffect, useRef, useState, forwardRef } from '@wordpress/element';
20
+ import { cloneElement, createInterpolateElement, isValidElement, useLayoutEffect, useRef, useState, forwardRef } from '@wordpress/element';
22
21
  import { __ } from '@wordpress/i18n';
23
22
  import { category, chevronLeft, moreVertical } from '@wordpress/icons';
24
23
 
25
24
  /**
26
25
  * Internal dependencies
27
26
  */
28
- import { Footer, Notice, Button, TabbedNavigation, PluginInstaller, SectionHeader, HandoffMessage, Page, Waiting } from '../';
29
- import { activeBreadcrumbs, appendSectionName } from './breadcrumbs-select';
27
+ import { Footer, DebugBadge, Button, TabbedNavigation, PluginInstaller, SectionHeader, HandoffMessage, Page, Waiting } from '../';
28
+ import { activeBreadcrumbs, activeSection, appendSectionName } from './breadcrumbs-select';
30
29
  import Router from '../proxied-imports/router';
31
30
  import registerStore, { WIZARD_STORE_NAMESPACE } from './store';
32
31
  import WizardSnackbar from './components/WizardSnackbar';
@@ -55,7 +54,7 @@ const resolveIcon = icon => {
55
54
  return icon;
56
55
  };
57
56
 
58
- const { HashRouter, Redirect, Route, Switch, useLocation } = Router;
57
+ const { HashRouter, Redirect, Route, Switch, matchPath, useLocation } = Router;
59
58
 
60
59
  /**
61
60
  * Interpolate a translated message's named tags, falling back to plain text.
@@ -92,7 +91,9 @@ const ResetHeaderData = () => {
92
91
  const location = useLocation();
93
92
  const { resetHeaderData } = useDispatch( WIZARD_STORE_NAMESPACE );
94
93
 
95
- useEffect( () => {
94
+ // Must stay before paint: a passive effect here would run after a section that
95
+ // publishes from a layout effect, wiping the header it just set.
96
+ useLayoutEffect( () => {
96
97
  resetHeaderData();
97
98
  window.scrollTo( 0, 0 );
98
99
  }, [ location.pathname, resetHeaderData ] );
@@ -104,9 +105,35 @@ const ResetHeaderData = () => {
104
105
  * Wizard header + content region. Rendered inside the wizard's HashRouter so it
105
106
  * can read the current route and derive the active-tab breadcrumb.
106
107
  */
107
- const WizardHeaderRegion = ( { hideHeader, headerText, sections, sectionName, subTitle, actions, tabbedNavigation, children } ) => {
108
+ const WizardHeaderRegion = ( {
109
+ hideHeader,
110
+ headerText,
111
+ sections,
112
+ sectionName,
113
+ subTitle,
114
+ actions,
115
+ tabbedNavigation: wizardTabbedNavigation,
116
+ children,
117
+ } ) => {
108
118
  const { pathname } = useLocation();
109
119
 
120
+ // A section can carry its own tabs, built from its route params, in place of
121
+ // the wizard's. The first match wins, as it does in the wizard's `<Switch>`.
122
+ let tabbedNavigation = wizardTabbedNavigation;
123
+ for ( const section of sections ) {
124
+ const match = matchPath( pathname, { path: section.path, exact: section.exact ?? false } );
125
+ if ( match ) {
126
+ if ( typeof section.tabbedNavigation === 'function' ) {
127
+ tabbedNavigation = (
128
+ <TabbedNavigation items={ section.tabbedNavigation( match.params ) }>
129
+ <WizardError />
130
+ </TabbedNavigation>
131
+ );
132
+ }
133
+ break;
134
+ }
135
+ }
136
+
110
137
  if ( hideHeader ) {
111
138
  // Without the Page shell the tabs still own the content: it renders
112
139
  // inside the active tab's panel.
@@ -124,8 +151,10 @@ const WizardHeaderRegion = ( { hideHeader, headerText, sections, sectionName, su
124
151
  // headerData.sectionName (deduped against the current trailing label).
125
152
  breadcrumbItems = appendSectionName( breadcrumbItems, sectionName );
126
153
 
154
+ const sectionSubTitle = activeSection( sections, pathname )?.subHeaderText;
155
+
127
156
  return (
128
- <Page breadcrumbItems={ breadcrumbItems } subTitle={ subTitle } actions={ actions } tabbedNavigation={ tabbedNavigation }>
157
+ <Page breadcrumbItems={ breadcrumbItems } subTitle={ sectionSubTitle ?? subTitle } actions={ actions } tabbedNavigation={ tabbedNavigation }>
129
158
  { children }
130
159
  </Page>
131
160
  );
@@ -137,7 +166,8 @@ const WizardHeaderRegion = ( { hideHeader, headerText, sections, sectionName, su
137
166
  * @property {string} [subHeaderText] The sub-header text, optional.
138
167
  * @property {string} [apiSlug] The API slug, optional.
139
168
  * @property {string} [className] CSS classes, optional.
140
- * @property {any[]} sections Array of sections.
169
+ * @property {any[]} sections Array of sections. A section's own `subHeaderText` replaces the wizard's while it is active.
170
+ * Its optional `tabbedNavigation( params )` returns the tab items shown while its route matches.
141
171
  * @property {boolean} [hasSimpleFooter] Indicates if a simple footer is used, optional.
142
172
  * @property {() => void} [renderAboveSections] Function to render content above sections, optional.
143
173
  * @property {string[]} [requiredPlugins] Array of required plugin strings, optional.
@@ -177,6 +207,7 @@ const Wizard = (
177
207
  actions,
178
208
  backNav,
179
209
  badges,
210
+ fullWidth: headerFullWidth,
180
211
  sectionDescription,
181
212
  sectionMenu,
182
213
  sectionName,
@@ -269,7 +300,7 @@ const Wizard = (
269
300
  // as page chrome rather than as content.
270
301
  const inertGating = window.newspack_aux_data?.inert_gating;
271
302
  const inertGatingNotice = inertGating?.show && (
272
- <CoreNotice status="warning" isDismissible={ false } className="newspack-wizard__inert-gating-notice">
303
+ <Notice status="warning" isDismissible={ false } className="newspack-wizard__inert-gating-notice">
273
304
  { /* The conversion map takes childless elements and fills them from the
274
305
  translated string, so jsx-a11y can't see the content they end up with. */ }
275
306
  { interpolateOrPlainText( inertGating.message, {
@@ -279,7 +310,7 @@ const Wizard = (
279
310
  /* eslint-enable jsx-a11y/anchor-has-content */
280
311
  strong: <strong />,
281
312
  } ) }
282
- </CoreNotice>
313
+ </Notice>
283
314
  );
284
315
 
285
316
  const content = (
@@ -303,7 +334,7 @@ const Wizard = (
303
334
  render={ routerProps => (
304
335
  <div
305
336
  className={ classnames( 'newspack-wizard__content', className, {
306
- 'newspack-wizard__content--full-width': section.fullWidth,
337
+ 'newspack-wizard__content--full-width': headerFullWidth ?? section.fullWidth,
307
338
  } ) }
308
339
  >
309
340
  { 'function' === typeof renderAboveSections ? renderAboveSections() : null }
@@ -403,7 +434,7 @@ const Wizard = (
403
434
  } ) }
404
435
  >
405
436
  <HashRouter hashType="slash">
406
- { newspack_aux_data.is_debug_mode && <Notice debugMode /> }
437
+ <DebugBadge />
407
438
  <WizardHeaderRegion
408
439
  hideHeader={ hideHeader }
409
440
  headerText={ headerText }
@@ -7,19 +7,22 @@ import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'
7
7
  * WordPress dependencies.
8
8
  */
9
9
  import apiFetch from '@wordpress/api-fetch';
10
+ import { select, useDispatch } from '@wordpress/data';
11
+ import { useLayoutEffect, useState } from '@wordpress/element';
10
12
 
11
13
  /**
12
14
  * Internal dependencies.
13
15
  */
14
16
  import Wizard from './';
15
17
  import { useWizardData } from './store/utils';
18
+ import { WIZARD_STORE_NAMESPACE } from './store';
16
19
 
17
20
  jest.mock( '@wordpress/api-fetch', () => jest.fn() );
18
21
 
19
- // Both globals are localized onto every real wizard screen. The footer's
20
- // ExternalLink needs a string href, and the debug Notice reads aux data.
21
- window.newspack_aux_data = { is_debug_mode: false };
22
+ // Localized onto every real wizard screen; the footer's ExternalLink needs a
23
+ // string href.
22
24
  window.newspack_urls = { support: 'https://help.newspack.com/' };
25
+ window.scrollTo = jest.fn();
23
26
 
24
27
  const SETTINGS = { minimumDonation: '5' };
25
28
 
@@ -154,3 +157,166 @@ describe( 'Wizard', () => {
154
157
  expect( counts.wizard ).toBe( 0 );
155
158
  } );
156
159
  } );
160
+
161
+ const NarrowingSection = () => {
162
+ const { setHeaderData } = useDispatch( WIZARD_STORE_NAMESPACE );
163
+ useLayoutEffect( () => {
164
+ setHeaderData( { fullWidth: false } );
165
+ }, [ setHeaderData ] );
166
+ return <div>Narrowing section</div>;
167
+ };
168
+
169
+ const RecoveringSection = () => {
170
+ const { setHeaderData } = useDispatch( WIZARD_STORE_NAMESPACE );
171
+ const [ failed, setFailed ] = useState( true );
172
+ useLayoutEffect( () => {
173
+ setHeaderData( { fullWidth: failed ? false : undefined } );
174
+ }, [ setHeaderData, failed ] );
175
+ return <button onClick={ () => setFailed( false ) }>Retry</button>;
176
+ };
177
+
178
+ // Two sections, because that is what mounts ResetHeaderData: a single-section
179
+ // wizard never exercises the reset these overrides have to survive.
180
+ const widthSections = renderSection => [
181
+ { label: 'List', path: '/', exact: true, fullWidth: true, render: renderSection },
182
+ { label: 'Other', path: '/other', render: () => <div>Other section</div> },
183
+ ];
184
+
185
+ describe( 'Wizard content width', () => {
186
+ beforeEach( () => {
187
+ apiFetch.mockReset();
188
+ } );
189
+
190
+ it( 'renders full-width when the section declares it and nothing overrides it', async () => {
191
+ const { container } = render(
192
+ <Wizard
193
+ headerText="Test wizard"
194
+ sections={ widthSections( () => (
195
+ <div>List content</div>
196
+ ) ) }
197
+ />
198
+ );
199
+
200
+ expect( await screen.findByText( 'List content' ) ).toBeInTheDocument();
201
+ expect( container.querySelector( '.newspack-wizard__content' ) ).toHaveClass( 'newspack-wizard__content--full-width' );
202
+ } );
203
+
204
+ it( 'falls back to the standard width when a section overrides fullWidth before paint', async () => {
205
+ const { container } = render(
206
+ <Wizard
207
+ headerText="Test wizard"
208
+ sections={ widthSections( () => (
209
+ <NarrowingSection />
210
+ ) ) }
211
+ />
212
+ );
213
+
214
+ expect( await screen.findByText( 'Narrowing section' ) ).toBeInTheDocument();
215
+ expect( container.querySelector( '.newspack-wizard__content' ) ).not.toHaveClass( 'newspack-wizard__content--full-width' );
216
+ } );
217
+
218
+ it( 'restores the declared width when the section clears its override', async () => {
219
+ const { container } = render(
220
+ <Wizard
221
+ headerText="Test wizard"
222
+ sections={ widthSections( () => (
223
+ <RecoveringSection />
224
+ ) ) }
225
+ />
226
+ );
227
+
228
+ const retry = await screen.findByRole( 'button', { name: 'Retry' } );
229
+ expect( container.querySelector( '.newspack-wizard__content' ) ).not.toHaveClass( 'newspack-wizard__content--full-width' );
230
+
231
+ fireEvent.click( retry );
232
+
233
+ await waitFor( () => expect( container.querySelector( '.newspack-wizard__content' ) ).toHaveClass( 'newspack-wizard__content--full-width' ) );
234
+ } );
235
+
236
+ it( 'keeps a section header published from a layout effect through the mount reset', async () => {
237
+ render(
238
+ <Wizard
239
+ headerText="Test wizard"
240
+ sections={ widthSections( () => (
241
+ <NarrowingSection />
242
+ ) ) }
243
+ />
244
+ );
245
+
246
+ expect( await screen.findByText( 'Narrowing section' ) ).toBeInTheDocument();
247
+ expect( select( WIZARD_STORE_NAMESPACE ).getHeaderData().fullWidth ).toBe( false );
248
+ } );
249
+ } );
250
+
251
+ describe( 'Wizard section tabs', () => {
252
+ const sections = [
253
+ { label: 'List', path: '/', exact: true, render: () => <div>List content</div> },
254
+ {
255
+ path: '/items/:itemId/logs/:tab?',
256
+ isHidden: true,
257
+ render: () => <div>Logs content</div>,
258
+ tabbedNavigation: ( { itemId } ) => [
259
+ { label: 'First Tab', path: `/items/${ itemId }/logs`, exact: true },
260
+ { label: 'Second Tab', path: `/items/${ itemId }/logs/second`, exact: true },
261
+ ],
262
+ },
263
+ ];
264
+
265
+ beforeEach( () => {
266
+ apiFetch.mockReset();
267
+ } );
268
+
269
+ afterEach( () => {
270
+ window.location.hash = '';
271
+ } );
272
+
273
+ it( 'shows the tabs a section builds from its route params while it matches', async () => {
274
+ window.location.hash = '#/items/42/logs/second';
275
+ render( <Wizard headerText="Test wizard" sections={ sections } /> );
276
+
277
+ expect( await screen.findByText( 'Logs content' ) ).toBeInTheDocument();
278
+ expect( screen.getByRole( 'tab', { name: 'First Tab' } ) ).toHaveAttribute( 'href', '#/items/42/logs' );
279
+ expect( screen.getByRole( 'tab', { name: 'Second Tab' } ) ).toHaveAttribute( 'aria-selected', 'true' );
280
+ } );
281
+
282
+ it( 'leaves the tabs out on a route the section does not match', async () => {
283
+ window.location.hash = '#/';
284
+ render( <Wizard headerText="Test wizard" sections={ sections } /> );
285
+
286
+ expect( await screen.findByText( 'List content' ) ).toBeInTheDocument();
287
+ expect( screen.queryByRole( 'tab' ) ).toBeNull();
288
+ } );
289
+ } );
290
+
291
+ describe( 'Wizard section subtitle', () => {
292
+ beforeEach( () => {
293
+ apiFetch.mockReset();
294
+ apiFetch.mockResolvedValue( {} );
295
+ } );
296
+
297
+ it( "shows the active section's subHeaderText in the header, falling back to the wizard's", async () => {
298
+ window.location.hash = '#/list';
299
+ const { container } = render(
300
+ <Wizard
301
+ headerText="Test wizard"
302
+ subHeaderText="Wizard intro"
303
+ sections={ [
304
+ { label: 'List', path: '/list', exact: true, subHeaderText: 'List intro', render: () => <div>List view</div> },
305
+ { label: 'Item', path: '/list/:id', isHidden: true, render: () => <div>Item view</div> },
306
+ ] }
307
+ />
308
+ );
309
+ const subtitle = () => container.querySelector( '.newspack-page__header-subtitle' )?.textContent;
310
+
311
+ expect( await screen.findByText( 'List view' ) ).toBeInTheDocument();
312
+ expect( subtitle() ).toBe( 'List intro' );
313
+
314
+ act( () => {
315
+ window.location.hash = '#/list/esp';
316
+ window.dispatchEvent( new HashChangeEvent( 'hashchange' ) );
317
+ } );
318
+
319
+ expect( await screen.findByText( 'Item view' ) ).toBeInTheDocument();
320
+ expect( subtitle() ).toBe( 'Wizard intro' );
321
+ } );
322
+ } );
@@ -16,6 +16,8 @@ import { createAction } from './utils.js';
16
16
  export const WIZARD_STORE_NAMESPACE = 'newspack/wizards';
17
17
 
18
18
  const DEFAULT_STATE = {
19
+ // `fullWidth` is deliberately absent: a default here would narrow every
20
+ // full-width section on reset, since Wizard only falls back when it is unset.
19
21
  headerData: {
20
22
  actions: [],
21
23
  backNav: '',