newspack-components 4.7.0 → 4.8.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 (251) hide show
  1. package/CHANGELOG.md +20 -0
  2. package/DEVELOPMENT.md +42 -21
  3. package/dist/cjs/_mixins.scss +13 -0
  4. package/dist/cjs/_variables.scss +12 -0
  5. package/dist/cjs/action-card/index.js +18 -10
  6. package/dist/cjs/action-card/index.test.js +91 -0
  7. package/dist/cjs/action-card/style.scss +2 -40
  8. package/dist/cjs/button/index.js +8 -3
  9. package/dist/cjs/button/index.test.js +61 -5
  10. package/dist/cjs/card/core-card.js +0 -2
  11. package/dist/cjs/card/style-core.scss +7 -5
  12. package/dist/cjs/card-feature/index.js +75 -61
  13. package/dist/cjs/card-feature/index.test.js +396 -54
  14. package/dist/cjs/card-feature/style.scss +20 -31
  15. package/dist/cjs/card-form/index.js +5 -5
  16. package/dist/cjs/card-sortable-list/index.js +5 -3
  17. package/dist/cjs/card-sortable-list/index.test.js +50 -0
  18. package/dist/cjs/collapsible-group/context.js +19 -0
  19. package/dist/cjs/collapsible-group/index.js +24 -0
  20. package/dist/cjs/collapsible-group/index.test.js +215 -0
  21. package/dist/cjs/collapsible-group/item.js +61 -0
  22. package/dist/cjs/collapsible-group/root.js +62 -0
  23. package/dist/cjs/collapsible-group/style.scss +47 -0
  24. package/dist/cjs/collapsible-group/types.js +5 -0
  25. package/dist/cjs/empty-state/actions.js +45 -0
  26. package/dist/cjs/empty-state/context.js +41 -0
  27. package/dist/cjs/empty-state/header.js +66 -0
  28. package/dist/cjs/empty-state/index.js +31 -0
  29. package/dist/cjs/empty-state/index.test.js +303 -0
  30. package/dist/cjs/empty-state/root.js +56 -0
  31. package/dist/cjs/empty-state/style.scss +51 -0
  32. package/dist/cjs/empty-state/types.js +5 -0
  33. package/dist/cjs/index.js +34 -20
  34. package/dist/cjs/info-button/index.js +59 -43
  35. package/dist/cjs/info-button/index.test.js +199 -0
  36. package/dist/cjs/info-button/style.scss +41 -9
  37. package/dist/cjs/modal/style.scss +12 -12
  38. package/dist/cjs/newspack-icon/style.scss +4 -4
  39. package/dist/cjs/notice/style.scss +3 -3
  40. package/dist/cjs/page/style.scss +16 -16
  41. package/dist/cjs/plugin-installer/index.js +5 -9
  42. package/dist/cjs/plugin-installer/style.scss +3 -14
  43. package/dist/cjs/section-header/index.js +31 -18
  44. package/dist/cjs/section-header/index.test.js +40 -0
  45. package/dist/cjs/section-header/style.scss +22 -6
  46. package/dist/cjs/settings/DateRangeSetting.js +309 -0
  47. package/dist/cjs/settings/SettingsSection.js +19 -5
  48. package/dist/cjs/settings/SettingsSection.test.js +49 -0
  49. package/dist/cjs/settings/index.js +3 -1
  50. package/dist/cjs/settings/style.scss +40 -7
  51. package/dist/cjs/stat-card/body.js +41 -0
  52. package/dist/cjs/stat-card/constants.js +11 -0
  53. package/dist/cjs/stat-card/context.js +54 -0
  54. package/dist/cjs/stat-card/delta.js +91 -0
  55. package/dist/cjs/stat-card/footer.js +72 -0
  56. package/dist/cjs/stat-card/index.js +47 -0
  57. package/dist/cjs/stat-card/index.test.js +938 -0
  58. package/dist/cjs/stat-card/label.js +66 -0
  59. package/dist/cjs/stat-card/root.js +69 -0
  60. package/dist/cjs/stat-card/secondary.js +38 -0
  61. package/dist/cjs/stat-card/style.scss +89 -0
  62. package/dist/cjs/stat-card/types.js +5 -0
  63. package/dist/cjs/stat-card/value.js +78 -0
  64. package/dist/cjs/status-indicator/index.js +66 -0
  65. package/dist/cjs/status-indicator/index.test.js +101 -0
  66. package/dist/cjs/status-indicator/statuses.js +39 -0
  67. package/dist/cjs/status-indicator/style.scss +9 -0
  68. package/dist/cjs/status-indicator/types.js +5 -0
  69. package/dist/cjs/style.scss +9 -9
  70. package/dist/cjs/tabbed-navigation/style.scss +6 -4
  71. package/dist/cjs/types.js +5 -0
  72. package/dist/cjs/web-preview/style.scss +11 -11
  73. package/dist/cjs/with-wizard/style.scss +10 -10
  74. package/dist/cjs/with-wizard-screen/style.scss +9 -9
  75. package/dist/cjs/wizard/index.js +33 -4
  76. package/dist/cjs/wizard/store/index.js +23 -1
  77. package/dist/esm/_mixins.scss +13 -0
  78. package/dist/esm/_variables.scss +12 -0
  79. package/dist/esm/action-card/index.js +18 -10
  80. package/dist/esm/action-card/index.test.js +89 -0
  81. package/dist/esm/action-card/style.scss +2 -40
  82. package/dist/esm/button/index.js +9 -4
  83. package/dist/esm/button/index.test.js +63 -5
  84. package/dist/esm/card/core-card.js +0 -2
  85. package/dist/esm/card/style-core.scss +7 -5
  86. package/dist/esm/card-feature/index.js +78 -62
  87. package/dist/esm/card-feature/index.test.js +397 -55
  88. package/dist/esm/card-feature/style.scss +20 -31
  89. package/dist/esm/card-form/index.js +5 -5
  90. package/dist/esm/card-sortable-list/index.js +6 -4
  91. package/dist/esm/card-sortable-list/index.test.js +46 -0
  92. package/dist/esm/collapsible-group/context.js +13 -0
  93. package/dist/esm/collapsible-group/index.js +17 -0
  94. package/dist/esm/collapsible-group/index.test.js +215 -0
  95. package/dist/esm/collapsible-group/item.js +53 -0
  96. package/dist/esm/collapsible-group/root.js +54 -0
  97. package/dist/esm/collapsible-group/style.scss +47 -0
  98. package/dist/esm/collapsible-group/types.js +1 -0
  99. package/dist/esm/empty-state/actions.js +37 -0
  100. package/dist/esm/empty-state/context.js +35 -0
  101. package/dist/esm/empty-state/header.js +58 -0
  102. package/dist/esm/empty-state/index.js +23 -0
  103. package/dist/esm/empty-state/index.test.js +299 -0
  104. package/dist/esm/empty-state/root.js +56 -0
  105. package/dist/esm/empty-state/style.scss +51 -0
  106. package/dist/esm/empty-state/types.js +1 -0
  107. package/dist/esm/index.js +4 -2
  108. package/dist/esm/info-button/index.js +54 -43
  109. package/dist/esm/info-button/index.test.js +195 -0
  110. package/dist/esm/info-button/style.scss +41 -9
  111. package/dist/esm/modal/style.scss +12 -12
  112. package/dist/esm/newspack-icon/style.scss +4 -4
  113. package/dist/esm/notice/style.scss +3 -3
  114. package/dist/esm/page/style.scss +16 -16
  115. package/dist/esm/plugin-installer/index.js +5 -9
  116. package/dist/esm/plugin-installer/style.scss +3 -14
  117. package/dist/esm/section-header/index.js +27 -14
  118. package/dist/esm/section-header/index.test.js +36 -0
  119. package/dist/esm/section-header/style.scss +22 -6
  120. package/dist/esm/settings/DateRangeSetting.js +304 -0
  121. package/dist/esm/settings/SettingsSection.js +16 -3
  122. package/dist/esm/settings/SettingsSection.test.js +45 -0
  123. package/dist/esm/settings/index.js +3 -1
  124. package/dist/esm/settings/style.scss +40 -7
  125. package/dist/esm/stat-card/body.js +36 -0
  126. package/dist/esm/stat-card/constants.js +5 -0
  127. package/dist/esm/stat-card/context.js +48 -0
  128. package/dist/esm/stat-card/delta.js +86 -0
  129. package/dist/esm/stat-card/footer.js +67 -0
  130. package/dist/esm/stat-card/index.js +33 -0
  131. package/dist/esm/stat-card/index.test.js +939 -0
  132. package/dist/esm/stat-card/label.js +61 -0
  133. package/dist/esm/stat-card/root.js +64 -0
  134. package/dist/esm/stat-card/secondary.js +33 -0
  135. package/dist/esm/stat-card/style.scss +89 -0
  136. package/dist/esm/stat-card/types.js +1 -0
  137. package/dist/esm/stat-card/value.js +73 -0
  138. package/dist/esm/status-indicator/index.js +52 -0
  139. package/dist/esm/status-indicator/index.test.js +96 -0
  140. package/dist/esm/status-indicator/statuses.js +33 -0
  141. package/dist/esm/status-indicator/style.scss +9 -0
  142. package/dist/esm/status-indicator/types.js +1 -0
  143. package/dist/esm/style.scss +9 -9
  144. package/dist/esm/tabbed-navigation/style.scss +6 -4
  145. package/dist/esm/types.js +1 -0
  146. package/dist/esm/web-preview/style.scss +11 -11
  147. package/dist/esm/with-wizard/style.scss +10 -10
  148. package/dist/esm/with-wizard-screen/style.scss +9 -9
  149. package/dist/esm/wizard/index.js +38 -7
  150. package/dist/esm/wizard/store/index.js +23 -1
  151. package/package.json +1 -1
  152. package/src/_mixins.scss +13 -0
  153. package/src/_variables.scss +12 -0
  154. package/src/action-card/action-card.d.ts +3 -2
  155. package/src/action-card/index.js +12 -12
  156. package/src/action-card/index.test.js +56 -0
  157. package/src/action-card/style.scss +2 -40
  158. package/src/button/index.test.js +52 -5
  159. package/src/button/index.tsx +9 -4
  160. package/src/card/core-card.js +0 -2
  161. package/src/card/style-core.scss +7 -5
  162. package/src/card-feature/README.md +68 -27
  163. package/src/card-feature/index.test.js +242 -27
  164. package/src/card-feature/index.tsx +77 -67
  165. package/src/card-feature/style.scss +20 -31
  166. package/src/card-form/README.md +10 -10
  167. package/src/card-form/index.tsx +4 -8
  168. package/src/card-sortable-list/README.md +4 -5
  169. package/src/card-sortable-list/index.test.js +29 -0
  170. package/src/card-sortable-list/index.tsx +7 -4
  171. package/src/collapsible-group/README.md +50 -0
  172. package/src/collapsible-group/context.ts +13 -0
  173. package/src/collapsible-group/index.test.js +168 -0
  174. package/src/collapsible-group/index.tsx +17 -0
  175. package/src/collapsible-group/item.tsx +42 -0
  176. package/src/collapsible-group/root.tsx +42 -0
  177. package/src/collapsible-group/style.scss +47 -0
  178. package/src/collapsible-group/types.ts +22 -0
  179. package/src/drawer/README.md +2 -2
  180. package/src/empty-state/README.md +165 -0
  181. package/src/empty-state/actions.tsx +37 -0
  182. package/src/empty-state/context.ts +40 -0
  183. package/src/empty-state/header.tsx +48 -0
  184. package/src/empty-state/index.test.js +247 -0
  185. package/src/empty-state/index.tsx +22 -0
  186. package/src/empty-state/root.tsx +39 -0
  187. package/src/empty-state/style.scss +51 -0
  188. package/src/empty-state/types.ts +33 -0
  189. package/src/index.js +4 -2
  190. package/src/info-button/README.md +115 -0
  191. package/src/info-button/index.test.js +163 -0
  192. package/src/info-button/index.tsx +64 -0
  193. package/src/info-button/style.scss +41 -9
  194. package/src/modal/style.scss +12 -12
  195. package/src/newspack-icon/style.scss +4 -4
  196. package/src/notice/style.scss +3 -3
  197. package/src/page/style.scss +16 -16
  198. package/src/plugin-installer/index.js +3 -9
  199. package/src/plugin-installer/style.scss +3 -14
  200. package/src/section-header/index.js +27 -14
  201. package/src/section-header/index.test.js +28 -0
  202. package/src/section-header/style.scss +22 -6
  203. package/src/settings/DateRangeSetting.js +266 -0
  204. package/src/settings/SettingsSection.js +24 -4
  205. package/src/settings/SettingsSection.test.js +45 -0
  206. package/src/settings/index.js +2 -0
  207. package/src/settings/style.scss +40 -7
  208. package/src/stat-card/README.md +434 -0
  209. package/src/stat-card/body.tsx +28 -0
  210. package/src/stat-card/constants.ts +5 -0
  211. package/src/stat-card/context.ts +53 -0
  212. package/src/stat-card/delta.tsx +88 -0
  213. package/src/stat-card/footer.tsx +68 -0
  214. package/src/stat-card/index.test.js +818 -0
  215. package/src/stat-card/index.tsx +48 -0
  216. package/src/stat-card/label.tsx +61 -0
  217. package/src/stat-card/root.tsx +40 -0
  218. package/src/stat-card/secondary.tsx +27 -0
  219. package/src/stat-card/style.scss +89 -0
  220. package/src/stat-card/types.ts +84 -0
  221. package/src/stat-card/value.tsx +72 -0
  222. package/src/status-indicator/README.md +123 -0
  223. package/src/status-indicator/index.test.js +70 -0
  224. package/src/status-indicator/index.tsx +39 -0
  225. package/src/status-indicator/statuses.ts +35 -0
  226. package/src/status-indicator/style.scss +9 -0
  227. package/src/status-indicator/types.ts +28 -0
  228. package/src/style.scss +9 -9
  229. package/src/tabbed-navigation/style.scss +6 -4
  230. package/src/types.ts +25 -0
  231. package/src/web-preview/style.scss +11 -11
  232. package/src/with-wizard/style.scss +10 -10
  233. package/src/with-wizard-screen/style.scss +9 -9
  234. package/src/wizard/index.js +49 -7
  235. package/src/wizard/store/index.js +26 -1
  236. package/dist/cjs/accordion/index.js +0 -72
  237. package/dist/cjs/accordion/index.test.js +0 -80
  238. package/dist/cjs/accordion/style.scss +0 -35
  239. package/dist/cjs/badge/index.js +0 -32
  240. package/dist/cjs/badge/style.scss +0 -36
  241. package/dist/esm/accordion/index.js +0 -63
  242. package/dist/esm/accordion/index.test.js +0 -79
  243. package/dist/esm/accordion/style.scss +0 -35
  244. package/dist/esm/badge/index.js +0 -24
  245. package/dist/esm/badge/style.scss +0 -36
  246. package/src/accordion/index.js +0 -46
  247. package/src/accordion/index.test.js +0 -62
  248. package/src/accordion/style.scss +0 -35
  249. package/src/badge/index.tsx +0 -26
  250. package/src/badge/style.scss +0 -36
  251. package/src/info-button/index.js +0 -38
@@ -15,11 +15,11 @@ Cards sit side by side, so a bare "Enable" names no feature out of context. The
15
15
 
16
16
  | State | Condition | Button | Dropdown | Badge |
17
17
  |---|---|---|---|---|
18
- | **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 |
18
+ | **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
19
  | **Disabled** | `!enabled`, no requirements | "Enable" | Hidden | None |
20
- | **Enabled** | `enabled`, no requirements | "Configure" | Shown if `moreControls` provided | Success badge ("Enabled") |
20
+ | **Enabled** | `enabled`, no requirements | "Configure" | Shown if `moreControls` provided | Stable-intent badge ("Enabled") |
21
21
 
22
- The card content (title + description) is visually muted (gray text, lighter border) when `requirements` is set.
22
+ 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
23
 
24
24
  ## Basic usage
25
25
 
@@ -40,7 +40,21 @@ import { __ } from '@wordpress/i18n';
40
40
 
41
41
  ## With unmet requirements
42
42
 
43
- When `requirements` is set the button is disabled and an error badge displays the string. The title and description are visually muted.
43
+ When `requirements` is set, a high-intent badge displays the string and the title drops to the muted text colour. By default the requirement is treated as locked: the button is blocked and the "More" dropdown is hidden, so `onEnable` and `moreControls` have nothing to act on. `onConfigure` is unreachable whenever `requirements` is set at all, locked or not, because the button only reads "Configure" when there is no outstanding requirement.
44
+
45
+ A blocked button keeps its place in the tab order and is described by the high-intent badge, so a keyboard or screen-reader user reaches it and hears why it will not act. Don't wrap it in your own `disabled` handling, which would undo that.
46
+
47
+ ```tsx
48
+ import { __ } from '@wordpress/i18n';
49
+
50
+ <CardFeature
51
+ title={ __( 'Metered countdown', 'newspack-plugin' ) }
52
+ description={ __( 'Show a countdown banner letting readers know how many free views they have left.', 'newspack-plugin' ) }
53
+ requirements={ __( 'Requires an API-based ESP', 'newspack-plugin' ) }
54
+ />
55
+ ```
56
+
57
+ Set `requirementsActionable` when the button is how the reader clears the requirement. It stays clickable and routes to `onEnable`, and an enabled card keeps its "More" dropdown so the feature can still be turned off.
44
58
 
45
59
  ```tsx
46
60
  import { __ } from '@wordpress/i18n';
@@ -50,8 +64,9 @@ import { __ } from '@wordpress/i18n';
50
64
  description={ __( 'Show a countdown banner letting readers know how many free views they have left.', 'newspack-plugin' ) }
51
65
  enabled={ isEnabled }
52
66
  requirements={ __( 'Requires metering', 'newspack-plugin' ) }
53
- onEnable={ () => setEnabled( true ) }
54
- onConfigure={ () => history.push( '/settings/countdown' ) }
67
+ requirementsActionable
68
+ enableLabel={ __( 'Set up metering', 'newspack-plugin' ) }
69
+ onEnable={ () => history.push( '/settings/metering' ) }
55
70
  moreControls={ [
56
71
  { title: __( 'Disable', 'newspack-plugin' ), onClick: () => setEnabled( false ) },
57
72
  ] }
@@ -60,35 +75,50 @@ import { __ } from '@wordpress/i18n';
60
75
 
61
76
  ## With a custom icon
62
77
 
63
- The `icon` prop accepts an object, not a bare node. Pass `node` for the icon element, `fill` to control the SVG colour (applied via `currentColor`), `backgroundColor` for a container background, and `radius` for the corner treatment.
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, and `radius` for the corner treatment. A ready element renders exactly as given, with no container, background or radius, which is the escape hatch for an icon that already carries its own chrome.
79
+
80
+ A descriptor's container is always **40 × 40 px** with the SVG at **24 × 24 px**. Setting `backgroundColor` without a `radius` gives 2px corners; pass `radius: 'full'` for a circle.
64
81
 
65
- The icon container is always **40 × 40 px** with the SVG at **24 × 24 px**. `radius` only applies when `backgroundColor` is set.
82
+ `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
+
84
+ `fill` and `backgroundColor` take any CSS colour. Reach for the Newspack palette when the icon should read as ours, and pass a literal when it should carry a third party's colour.
66
85
 
67
86
  ```tsx
68
87
  import { __ } from '@wordpress/i18n';
69
88
  import { Icon, starFilled } from '@wordpress/icons';
89
+ import colors from 'newspack-colors';
70
90
 
71
- // Icon with fill only
91
+ // Newspack palette, fill only
72
92
  <CardFeature
73
93
  title={ __( 'Content gifting', 'newspack-plugin' ) }
74
94
  description={ __( 'Let subscribers share gated articles with non-subscribers.', 'newspack-plugin' ) }
75
- icon={ { node: <Icon icon={ starFilled } />, fill: '#003da5' } }
95
+ icon={ { node: <Icon icon={ starFilled } />, fill: colors[ 'primary-600' ] } }
76
96
  enabled={ isEnabled }
77
97
  onEnable={ handleEnable }
78
98
  onConfigure={ handleConfigure }
79
99
  moreControls={ [ { title: __( 'Disable', 'newspack-plugin' ), onClick: handleDisable } ] }
80
100
  />
81
101
 
82
- // Icon with circular background
102
+ // A vendor mark on its own brand background, keeping the mark's colours
83
103
  <CardFeature
84
- title={ __( 'Content gifting', 'newspack-plugin' ) }
85
- description={ __( 'Let subscribers share gated articles with non-subscribers.', 'newspack-plugin' ) }
86
- icon={ { node: <Icon icon={ starFilled } />, fill: '#003da5', backgroundColor: '#dfe7f4', radius: 'full' } }
104
+ title={ __( 'Mailchimp', 'newspack-plugin' ) }
105
+ description={ __( 'Sync reader activity with your Mailchimp audience.', 'newspack-plugin' ) }
106
+ icon={ { node: <MailchimpMark />, backgroundColor: '#ffe01b', radius: 'full' } }
87
107
  enabled={ isEnabled }
88
108
  onEnable={ handleEnable }
89
109
  onConfigure={ handleConfigure }
90
110
  moreControls={ [ { title: __( 'Disable', 'newspack-plugin' ), onClick: handleDisable } ] }
91
111
  />
112
+
113
+ // A ready element, rendered as-is
114
+ <CardFeature
115
+ title={ __( 'Mailchimp', 'newspack-plugin' ) }
116
+ description={ __( 'Sync reader activity with your Mailchimp audience.', 'newspack-plugin' ) }
117
+ icon={ <IntegrationIcon provider="mailchimp" /> }
118
+ enabled={ isEnabled }
119
+ onEnable={ handleEnable }
120
+ onConfigure={ handleConfigure }
121
+ />
92
122
  ```
93
123
 
94
124
  ## With custom button labels
@@ -112,7 +142,7 @@ import { __ } from '@wordpress/i18n';
112
142
 
113
143
  ## With a custom badge
114
144
 
115
- Override `badgeText` and `badgeLevel` to change the badge shown when the feature is enabled. Available levels: `default`, `info`, `success`, `warning`, `error`.
145
+ Override `badge` to change the badge shown when the feature is enabled. See [`BadgeIntent`](../types.ts) for the available intents.
116
146
 
117
147
  ```tsx
118
148
  import { __ } from '@wordpress/i18n';
@@ -121,8 +151,7 @@ import { __ } from '@wordpress/i18n';
121
151
  title={ __( 'Stripe', 'newspack-plugin' ) }
122
152
  description={ __( 'Accept payments via Stripe.', 'newspack-plugin' ) }
123
153
  enabled={ isEnabled }
124
- badgeText={ __( 'Live mode', 'newspack-plugin' ) }
125
- badgeLevel="info"
154
+ badge={ { label: __( 'Live mode', 'newspack-plugin' ), intent: 'informational' } }
126
155
  onEnable={ handleEnable }
127
156
  onConfigure={ () => history.push( '/settings/stripe' ) }
128
157
  moreControls={ [ { title: __( 'Disable', 'newspack-plugin' ), onClick: handleDisable } ] }
@@ -150,23 +179,34 @@ import { __ } from '@wordpress/i18n';
150
179
  />
151
180
  ```
152
181
 
182
+ ## What the layout rests on
183
+
184
+ The card is built on `Card.Root`, `Card.Header` and `Card.Content` from `@wordpress/ui`. Three things about that pairing decide how it renders.
185
+
186
+ **Cards in a row bottom-align their action rows.** `Card.Root` is `display: flex; flex-direction: column`, and `.newspack-card-feature__actions` takes `margin-top: auto`. Two cards with descriptions of different lengths still put their buttons on one baseline. No test covers this, because jsdom computes no layout. If a library update drops the column direction, the cards stop aligning and nothing fails.
187
+
188
+ **The action row sits in `Card.Content`, which the library documents as the main content area.** The description sits in `Card.Header`, so the two are inverted. The gap between them lands at zero while `--wpds-dimension-gap-xl` and `--wpds-dimension-padding-2xl` are both 24px, and `margin-top: auto` is unlayered, so it wins over the library's layered rule either way. Move the action row when the library ships a `Card.Footer`.
189
+
190
+ **The title keeps its own type size.** `Card.Title` renders at `heading-lg`, which is 15px, and this title is 20px. It uses `heading-x-large()` from `@wordpress/base-styles` instead. Switch to `<Card.Title render={ createElement( 'h3' ) }>` if the two sizes ever agree.
191
+
153
192
  ## Props
154
193
 
155
194
  | Prop | Type | Default | Description |
156
195
  |---|---|---|---|
157
196
  | `title` | `string` | — | Card heading (**required**) |
197
+ | `headingLevel` | `2`–`6` | `3` | Heading level for the title. The default sits under a `SectionHeader` or a `WizardsTab` heading, both of which are h2. Raise it when the cards sit under a deeper section label |
158
198
  | `description` | `string` | — | Supporting text below the title |
159
- | `icon` | `CardFeatureIcon` | — | Icon displayed on the right. See `CardFeatureIcon` below. |
199
+ | `icon` | `CardFeatureIcon \| ReactElement` | — | Icon displayed on the right. A descriptor gets the 40 × 40 container; a ready element renders as-is. See `CardFeatureIcon` below. |
160
200
  | `enabled` | `boolean` | `false` | Whether the feature is currently enabled |
161
- | `requirements` | `string` | — | When set, enters the unmet-requirements state; value is used as the error badge text |
201
+ | `requirements` | `string` | — | When set, enters the unmet-requirements state. The value is the high-intent badge text and the primary button's accessible description, so write it to read sensibly after the button's label |
162
202
  | `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) |
163
- | `enableLabel` | `string` | `"Enable"` | Primary button label when not enabled |
164
- | `configureLabel` | `string` | `"Configure"` | Primary button label when enabled |
165
- | `onEnable` | `() => void` | — | Called when the primary button is clicked and the feature is not enabled |
166
- | `onConfigure` | `() => void` | — | Called when the primary button is clicked and the feature is enabled |
203
+ | `enableLabel` | `string` | `"Enable"` | Label for the primary button in its "Enable" states: not enabled, or enabled with an unmet requirement |
204
+ | `configureLabel` | `string` | `"Configure"` | Label for the primary button in its "Configure" state: enabled, with no unmet requirement |
205
+ | `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 |
167
207
  | `moreControls` | `MoreControl[]` | — | Items for the "More" dropdown. Shown when `enabled` and either there are no `requirements` or `requirementsActionable` is set |
168
- | `badgeText` | `string` | `"Enabled"` | Badge text shown when enabled |
169
- | `badgeLevel` | `BadgeLevel` | `"success"` | Badge level shown when enabled |
208
+ | `badge` | `{ label?: string; intent?: BadgeIntent }` | `{ label: "Enabled", intent: "stable" }` | Badge shown when enabled. Ignored while `requirements` is set, which takes the badge |
209
+ | `busy` | `boolean` | `false` | Shows the primary button as busy and blocks it while an action is in flight |
170
210
  | `className` | `string` | — | Additional class name applied to the card element |
171
211
 
172
212
  ### `CardFeatureIcon`
@@ -177,7 +217,8 @@ type CardFeatureIcon = {
177
217
  fill?: string; // SVG fill colour (applied via currentColor)
178
218
  backgroundColor?: string; // Background colour of the 40×40 container
179
219
  radius?: 'small' | 'full'; // 'small' = 2px ($radius-small), 'full' = 50% ($radius-round)
180
- // Only applied when backgroundColor is set.
220
+ // Defaults to 'small' whenever backgroundColor is set,
221
+ // and has nothing to round without one.
181
222
  };
182
223
  ```
183
224
 
@@ -187,6 +228,6 @@ type CardFeatureIcon = {
187
228
  type MoreControl = {
188
229
  title: string;
189
230
  onClick: () => void;
190
- icon?: React.ReactNode;
231
+ icon?: JSX.Element;
191
232
  };
192
233
  ```
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * External dependencies.
3
3
  */
4
- import { render, screen } from '@testing-library/react';
4
+ import { fireEvent, render, screen } from '@testing-library/react';
5
5
 
6
6
  /**
7
7
  * WordPress dependencies.
@@ -20,46 +20,261 @@ jest.mock( '@wordpress/i18n', () => {
20
20
  return { ...actual, _x: jest.fn( text => text ) };
21
21
  } );
22
22
 
23
+ const primaryButton = () => screen.getByRole( 'button', { name: /Enable|Configure/ } );
24
+ const moreMenu = () => screen.queryByRole( 'button', { name: /^More options for/ } );
25
+
23
26
  describe( 'CardFeature', () => {
24
27
  beforeEach( () => {
25
28
  _x.mockClear();
26
29
  } );
27
30
 
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 );
31
+ describe( 'structure', () => {
32
+ it( 'renders the title as a level-3 heading and the description alongside it', () => {
33
+ render( <CardFeature title="Content gifting" description="Let subscribers share gated articles." /> );
34
+ expect( screen.getByRole( 'heading', { level: 3 } ) ).toHaveTextContent( 'Content gifting' );
35
+ expect( screen.getByText( 'Let subscribers share gated articles.' ) ).toBeInTheDocument();
36
+ } );
37
+
38
+ it( 'renders the title at the requested heading level', () => {
39
+ render( <CardFeature title="Content gifting" headingLevel={ 4 } /> );
40
+ expect( screen.getByRole( 'heading', { level: 4 } ) ).toHaveTextContent( 'Content gifting' );
41
+ expect( screen.queryByRole( 'heading', { level: 3 } ) ).not.toBeInTheDocument();
42
+ } );
43
+
44
+ it( 'omits the description paragraph when none is passed', () => {
45
+ const { container } = render( <CardFeature title="Content gifting" /> );
46
+ expect( container.querySelector( '.newspack-card-feature__description' ) ).toBeNull();
47
+ } );
33
48
  } );
34
49
 
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' );
50
+ describe( 'accessible names', () => {
51
+ it( 'names the feature in the primary button, keeping the visible label first', () => {
52
+ render( <CardFeature title="Metered Countdown" /> );
53
+ const button = screen.getByRole( 'button', { name: 'Enable Metered Countdown' } );
54
+ expect( button ).toHaveTextContent( 'Enable' );
55
+ expect( button.getAttribute( 'aria-label' ).startsWith( button.textContent ) ).toBe( true );
56
+ } );
57
+
58
+ it( 'carries a custom label into the accessible name', () => {
59
+ render( <CardFeature title="Subscription retention" enableLabel="Change" /> );
60
+ expect( screen.getByRole( 'button', { name: 'Change Subscription retention' } ) ).toHaveTextContent( 'Change' );
61
+ } );
62
+
63
+ it( 'names the feature in the configure state too', () => {
64
+ render( <CardFeature title="Content Gifting" enabled /> );
65
+ expect( screen.getByRole( 'button', { name: 'Configure Content Gifting' } ) ).toBeInTheDocument();
66
+ } );
67
+
68
+ it( 'names the feature in the More menu', () => {
69
+ render( <CardFeature title="Content Gifting" enabled moreControls={ [ { title: 'Disable', onClick: () => {} } ] } /> );
70
+ expect( screen.getByRole( 'button', { name: 'More options for Content Gifting' } ) ).toBeInTheDocument();
71
+ } );
72
+
73
+ it( 'gives the accessible-name order its own catalogue entry', () => {
74
+ render( <CardFeature title="Metered Countdown" /> );
75
+ expect( _x ).toHaveBeenCalledWith( '%1$s %2$s', 'accessible button name: visible action label, then feature name', 'newspack-plugin' );
76
+ } );
77
+
78
+ it( 'distinguishes two cards that share a button label', () => {
79
+ render(
80
+ <>
81
+ <CardFeature title="Metered Countdown" />
82
+ <CardFeature title="Content Gifting" />
83
+ </>
84
+ );
85
+ expect( screen.getByRole( 'button', { name: 'Enable Metered Countdown' } ) ).toBeInTheDocument();
86
+ expect( screen.getByRole( 'button', { name: 'Enable Content Gifting' } ) ).toBeInTheDocument();
87
+ } );
38
88
  } );
39
89
 
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();
90
+ describe( 'primary button', () => {
91
+ it( 'reads Enable and calls onEnable when the feature is off', () => {
92
+ const onEnable = jest.fn();
93
+ const onConfigure = jest.fn();
94
+ render( <CardFeature title="Content gifting" onEnable={ onEnable } onConfigure={ onConfigure } /> );
95
+ expect( primaryButton() ).toHaveTextContent( 'Enable' );
96
+ fireEvent.click( primaryButton() );
97
+ expect( onEnable ).toHaveBeenCalledTimes( 1 );
98
+ expect( onConfigure ).not.toHaveBeenCalled();
99
+ } );
100
+
101
+ it( 'reads Configure and calls onConfigure once enabled', () => {
102
+ const onEnable = jest.fn();
103
+ const onConfigure = jest.fn();
104
+ render( <CardFeature title="Content gifting" enabled onEnable={ onEnable } onConfigure={ onConfigure } /> );
105
+ expect( primaryButton() ).toHaveTextContent( 'Configure' );
106
+ fireEvent.click( primaryButton() );
107
+ expect( onConfigure ).toHaveBeenCalledTimes( 1 );
108
+ expect( onEnable ).not.toHaveBeenCalled();
109
+ } );
110
+
111
+ it( 'still routes to onEnable when enabled with an unmet requirement, since the button reads Enable', () => {
112
+ const onEnable = jest.fn();
113
+ const onConfigure = jest.fn();
114
+ render(
115
+ <CardFeature
116
+ title="Content gifting"
117
+ enabled
118
+ requirements="Requires metering"
119
+ requirementsActionable
120
+ onEnable={ onEnable }
121
+ onConfigure={ onConfigure }
122
+ />
123
+ );
124
+ expect( primaryButton() ).toHaveTextContent( 'Enable' );
125
+ fireEvent.click( primaryButton() );
126
+ expect( onEnable ).toHaveBeenCalledTimes( 1 );
127
+ expect( onConfigure ).not.toHaveBeenCalled();
128
+ } );
129
+
130
+ it( 'keeps the button live on an actionable requirement before the feature is on, which is how Activate reaches the user', () => {
131
+ const onEnable = jest.fn();
132
+ render(
133
+ <CardFeature
134
+ title="Mailchimp"
135
+ requirements="Requires WooCommerce"
136
+ requirementsActionable
137
+ enableLabel="Activate"
138
+ onEnable={ onEnable }
139
+ />
140
+ );
141
+ const button = screen.getByRole( 'button', { name: 'Activate Mailchimp' } );
142
+ expect( button ).not.toHaveAttribute( 'aria-disabled' );
143
+ fireEvent.click( button );
144
+ expect( onEnable ).toHaveBeenCalledTimes( 1 );
145
+ expect( screen.getByText( 'Requires WooCommerce' ) ).toBeInTheDocument();
146
+ } );
147
+
148
+ it( 'does not fire when a requirement is not actionable, but stays reachable', () => {
149
+ const onEnable = jest.fn();
150
+ render( <CardFeature title="Content gifting" requirements="Managed by site configuration" onEnable={ onEnable } /> );
151
+ expect( primaryButton() ).toHaveAttribute( 'aria-disabled', 'true' );
152
+ expect( primaryButton() ).not.toHaveAttribute( 'disabled' );
153
+ fireEvent.click( primaryButton() );
154
+ expect( onEnable ).not.toHaveBeenCalled();
155
+ } );
156
+
157
+ it( 'points the blocked button at the badge that explains why', () => {
158
+ render( <CardFeature title="Content gifting" requirements="Managed by site configuration" /> );
159
+ const describedBy = primaryButton().getAttribute( 'aria-describedby' );
160
+ expect( describedBy ).toBeTruthy();
161
+ expect( document.getElementById( describedBy ) ).toHaveTextContent( 'Managed by site configuration' );
162
+ } );
163
+
164
+ it( 'leaves the enabled badge unlinked, since it explains nothing about the button', () => {
165
+ render( <CardFeature title="Content gifting" enabled /> );
166
+ expect( primaryButton() ).not.toHaveAttribute( 'aria-describedby' );
167
+ } );
168
+
169
+ it( 'disables the button while an action is in flight, even with an actionable requirement', () => {
170
+ const onEnable = jest.fn();
171
+ const { rerender } = render( <CardFeature title="Content gifting" busy onEnable={ onEnable } /> );
172
+ expect( primaryButton() ).toHaveAttribute( 'aria-disabled', 'true' );
173
+ fireEvent.click( primaryButton() );
174
+ expect( onEnable ).not.toHaveBeenCalled();
175
+
176
+ rerender( <CardFeature title="Content gifting" busy requirements="Requires metering" requirementsActionable onEnable={ onEnable } /> );
177
+ expect( primaryButton() ).toHaveAttribute( 'aria-disabled', 'true' );
178
+ } );
179
+
180
+ it( 'accepts custom labels for both states', () => {
181
+ const { rerender } = render( <CardFeature title="Apple News" enableLabel="Connect" configureLabel="Manage connection" /> );
182
+ expect( screen.getByRole( 'button', { name: 'Connect Apple News' } ) ).toBeInTheDocument();
183
+ rerender( <CardFeature title="Apple News" enabled enableLabel="Connect" configureLabel="Manage connection" /> );
184
+ expect( screen.getByRole( 'button', { name: 'Manage connection Apple News' } ) ).toBeInTheDocument();
185
+ } );
186
+ } );
187
+
188
+ describe( 'badge', () => {
189
+ it( 'shows nothing when off, and the enabled badge when on', () => {
190
+ const { rerender } = render( <CardFeature title="Content gifting" /> );
191
+ expect( screen.queryByText( 'Enabled' ) ).not.toBeInTheDocument();
192
+ rerender( <CardFeature title="Content gifting" enabled /> );
193
+ expect( screen.getByText( 'Enabled' ) ).toBeInTheDocument();
194
+ } );
195
+
196
+ it( 'lets the requirements badge win over the enabled badge', () => {
197
+ render( <CardFeature title="Content gifting" enabled requirements="Requires metering" /> );
198
+ expect( screen.getByText( 'Requires metering' ) ).toBeInTheDocument();
199
+ expect( screen.queryByText( 'Enabled' ) ).not.toBeInTheDocument();
200
+ } );
201
+
202
+ it( 'accepts custom badge text', () => {
203
+ render( <CardFeature title="Stripe" enabled badge={ { label: 'Live mode', intent: 'informational' } } /> );
204
+ expect( screen.getByText( 'Live mode' ) ).toBeInTheDocument();
205
+ } );
206
+ } );
207
+
208
+ describe( 'More menu', () => {
209
+ const controls = [ { title: 'Disable', onClick: jest.fn() } ];
210
+
211
+ it( 'appears only when enabled and controls are supplied', () => {
212
+ const { rerender } = render( <CardFeature title="Content gifting" moreControls={ controls } /> );
213
+ expect( moreMenu() ).not.toBeInTheDocument();
214
+ rerender( <CardFeature title="Content gifting" enabled /> );
215
+ expect( moreMenu() ).not.toBeInTheDocument();
216
+ rerender( <CardFeature title="Content gifting" enabled moreControls={ controls } /> );
217
+ expect( moreMenu() ).toBeInTheDocument();
218
+ } );
219
+
220
+ it( 'stays available on an actionable requirement and hides on a locked one', () => {
221
+ const { rerender } = render(
222
+ <CardFeature title="Content gifting" enabled requirements="Requires metering" requirementsActionable moreControls={ controls } />
223
+ );
224
+ expect( moreMenu() ).toBeInTheDocument();
225
+ rerender( <CardFeature title="Content gifting" enabled requirements="Managed by site configuration" moreControls={ controls } /> );
226
+ expect( moreMenu() ).not.toBeInTheDocument();
227
+ } );
43
228
  } );
44
229
 
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();
230
+ describe( 'icon', () => {
231
+ it( 'renders a ready element as-is, without the descriptor container', () => {
232
+ const { container } = render( <CardFeature title="Content gifting" icon={ <span data-testid="ready-icon" /> } /> );
233
+ expect( screen.getByTestId( 'ready-icon' ) ).toBeInTheDocument();
234
+ expect( container.querySelector( '.newspack-card-feature__icon' ) ).toBeNull();
235
+ } );
236
+
237
+ it( 'applies the descriptor colours inline and rounds fully on request', () => {
238
+ const { container } = render(
239
+ <CardFeature
240
+ title="Content gifting"
241
+ icon={ { node: <span data-testid="descriptor-icon" />, fill: '#003da5', backgroundColor: '#dfe7f4', radius: 'full' } }
242
+ />
243
+ );
244
+ const iconContainer = container.querySelector( '.newspack-card-feature__icon' );
245
+ expect( screen.getByTestId( 'descriptor-icon' ) ).toBeInTheDocument();
246
+ expect( iconContainer ).toHaveClass( 'newspack-card-feature__icon--radius-full' );
247
+ expect( iconContainer ).toHaveStyle( { backgroundColor: '#dfe7f4', color: '#003da5' } );
248
+ } );
249
+
250
+ it( 'hides the descriptor container from assistive tech, since the title already names the feature', () => {
251
+ const { container } = render( <CardFeature title="Content gifting" icon={ { node: <svg /> } } /> );
252
+ expect( container.querySelector( '.newspack-card-feature__icon' ) ).toHaveAttribute( 'aria-hidden', 'true' );
253
+ } );
254
+
255
+ it( 'falls back to small corners when a background is set without a radius', () => {
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', () => {
263
+ const { container } = render( <CardFeature title="Content gifting" icon={ { node: <span />, fill: '#003da5' } } /> );
264
+ const iconContainer = container.querySelector( '.newspack-card-feature__icon' );
265
+ expect( iconContainer ).not.toHaveClass( 'newspack-card-feature__icon--radius-small' );
266
+ } );
48
267
  } );
49
268
 
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' );
269
+ it( 'passes className through to the card element', () => {
270
+ const { container } = render( <CardFeature title="Content gifting" className="newspack-subscribers__card" /> );
271
+ expect( container.querySelector( '.newspack-card-feature' ) ).toHaveClass( 'newspack-subscribers__card' );
53
272
  } );
54
273
 
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();
274
+ it( 'marks the card as muted only when requirements are set', () => {
275
+ const { container, rerender } = render( <CardFeature title="Content gifting" enabled /> );
276
+ expect( container.querySelector( '.newspack-card-feature--muted' ) ).toBeNull();
277
+ rerender( <CardFeature title="Content gifting" enabled requirements="Requires metering" /> );
278
+ expect( container.querySelector( '.newspack-card-feature--muted' ) ).toBeInTheDocument();
64
279
  } );
65
280
  } );