newspack-components 4.6.2 → 4.7.0-alpha.1

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