newspack-components 4.5.0 → 4.6.0

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