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
@@ -0,0 +1,434 @@
1
+ # StatCard
2
+
3
+ One figure presented as a scorecard: what it is, the number itself, and what the
4
+ number counts. Cards sit in a row, so they share a type scale and a null
5
+ treatment rather than each screen inventing its own.
6
+
7
+ The API is compound: a `StatCard.Root` and one subcomponent per slot. As `Drawer`
8
+ does, the parts hang off one exported object, which keeps a seven-part component
9
+ to one name on the barrel.
10
+
11
+ ## Importing
12
+
13
+ The package barrel and the component's own entry point both work:
14
+
15
+ ```jsx
16
+ // The barrel.
17
+ import { StatCard } from 'newspack-components';
18
+
19
+ // The component on its own.
20
+ import StatCard from '../../packages/components/src/stat-card';
21
+ ```
22
+
23
+ Both are safe. `Card.Root` from `@wordpress/ui` takes its background, border,
24
+ radius and padding from `--wpds-*` custom properties, which arrive with the
25
+ design-token sheet that `page/style.scss` imports, and that sheet rides in with
26
+ the barrel. But the CSS `@wordpress/ui` actually ships carries a fallback on
27
+ each of those properties, and the fallbacks are the same light-theme values the
28
+ token sheet sets, so a card outside the sheet renders the same chrome. Only a
29
+ consumer opting into non-default theme settings, a different corner radius say,
30
+ would see the two diverge.
31
+
32
+ The figure is unaffected either way: the card declares the Newspack accent on
33
+ itself rather than relying on the package's global remap. It declares the darker
34
+ and lighter steps with it, which its own rules never touch, so that a control in
35
+ a slot, a button in `suffix` or in `Footer`, takes the same accent as the figure
36
+ for its fill, its hover and its focus ring. Through the barrel those values are
37
+ already global and the block changes nothing; it earns its keep on the
38
+ deep-import route the rest of this package recommends.
39
+
40
+ The exported prop types travel with neither route. The barrel is a `.js` file so
41
+ it cannot re-export types, and the package ships no declarations (it compiles
42
+ with Babel and sets no `types` field), so `StatCardRootProps` and its siblings
43
+ are reachable only through a path import into `src/stat-card` from inside this
44
+ monorepo.
45
+
46
+ ## Usage
47
+
48
+ ```jsx
49
+ import { __ } from '@wordpress/i18n';
50
+ import { StatCard } from 'newspack-components';
51
+
52
+ <StatCard.Root>
53
+ <StatCard.Label>{ __( 'Subscribers reached', 'newspack-plugin' ) }</StatCard.Label>
54
+ <StatCard.Body>
55
+ <StatCard.Value value="1,284" />
56
+ </StatCard.Body>
57
+ <StatCard.Footer>
58
+ { __( 'Readers who received at least one campaign this month.', 'newspack-plugin' ) }
59
+ </StatCard.Footer>
60
+ </StatCard.Root>
61
+ ```
62
+
63
+ Every slot except `Root` is optional. `Body` is what pins `Footer` to the bottom
64
+ of the card, so a row of cards with descriptions of different lengths still has
65
+ its numbers on one line.
66
+
67
+ ## The figure is the caller's to format
68
+
69
+ `StatCard.Value` takes a string or a number, not an element. Currency symbols,
70
+ thousands separators, percentages, abbreviated millions and locale all belong to
71
+ the screen that knows what the figure means; the component only sizes it.
72
+
73
+ The one thing it does own is the absence of a figure. Pass `value={ null }` and
74
+ it renders the null glyph, standing in for "there is no number here" as opposed
75
+ to a zero that genuinely is one. `undefined` and a blank string take the same
76
+ path, so `value={ data?.count }` is safe before the data arrives, and a field
77
+ that reports "no data" as `""`, or as a padded sentinel, gets the glyph rather
78
+ than an empty hero.
79
+
80
+ A zero is a figure and renders as one. That distinction is the whole reason the
81
+ glyph exists, so nothing in the component may treat `0` as missing.
82
+
83
+ ## Scale and the container query
84
+
85
+ The hero figure is `clamp( 20px, 14cqi, 48px )`, against a
86
+ `container-type: inline-size` on `Root`. A four-figure number in a narrow column
87
+ shrinks to fit rather than overflowing or forcing a smaller fixed size on every
88
+ card in the row, and the floor stops it shrinking under its own label.
89
+
90
+ The ceiling and its ratio are `$font-size-3x-large` and
91
+ `$font-line-height-3x-large` in the package's `src/_variables.scss`, which
92
+ carries the steps this package needs above the `@wordpress/base-styles` scale.
93
+ The line height is unitless on purpose: the font size is fluid, so a fixed
94
+ value from the base-styles pairs would drift out of proportion as the figure
95
+ shrinks.
96
+
97
+ That query is why the parts insist on a `Root`: a `StatCard.Value` rendered
98
+ loose would size against whichever container it happened to land in, which fails
99
+ quietly and looks like a styling bug.
100
+
101
+ Inline-size containment has a second consequence: the card contributes nothing
102
+ to its own intrinsic width, so **the parent layout has to give `Root` a definite
103
+ inline size**. A grid track or a `flex: 1` item is fine. Dropped somewhere its
104
+ width would come from its contents, such as an `inline-block` or a table cell,
105
+ it collapses to nothing. Equal widths across a row are what keep one type scale
106
+ across that row.
107
+
108
+ It also makes the card a containing block for `position: absolute` and
109
+ `position: fixed` descendants, and `Card.Root` clips its overflow. Anything
110
+ positioned that renders inline inside the card, such as a popover on a control
111
+ in `suffix`, is therefore trapped by the card unless it portals out.
112
+ `InfoButton` portals to the shared overlay slot and the tooltips and popovers in
113
+ `@wordpress/components` portal by default, so this mostly matters if a consumer
114
+ registers its own `Popover.Slot` inside a card.
115
+
116
+ For a hero that is a phrase rather than a number ("0 of 17", "No conversions"),
117
+ pass `variant="text"`. It keeps the slot and drops the display scale, which
118
+ would otherwise wrap a sentence across three lines.
119
+
120
+ ## Naming the figure to screen readers
121
+
122
+ A visible figure whose meaning rests on punctuation or a glyph needs saying
123
+ differently out loud. `valueLabel` replaces the spoken text: the visible span
124
+ goes `aria-hidden`, and the label follows in a `VisuallyHidden` from
125
+ `@wordpress/ui`, which brings its own CSS. The card asks nothing of the host
126
+ page for that, where wp-admin's `.screen-reader-text` would have made every
127
+ consumer's stylesheet part of the contract.
128
+
129
+ This is deliberately not `role="img"` with an `aria-label`. ARIA prohibits
130
+ naming a generic element, so the label needs a role to survive, and `img` makes
131
+ NVDA and VoiceOver announce "graphic" for what is a typographic placeholder.
132
+ Hiding the glyph and supplying real text avoids both.
133
+
134
+ The null glyph gets "Not applicable" by default, or whatever `labels` on the
135
+ `Root` puts in its place. Pass `valueLabel` to say something more specific, e.g.
136
+ why the figure is missing. An empty or blank `valueLabel` falls back to that
137
+ default rather than hiding the figure behind a name that says nothing.
138
+
139
+ ### Outside `newspack-plugin`
140
+
141
+ The spoken defaults, "Not applicable", "Up" and "Down", carry the
142
+ `newspack-plugin` text domain, as every string in this package does. WordPress
143
+ resolves JS translations per script handle, so a bundle registered against
144
+ another domain never loads them and they read in English.
145
+
146
+ `labels` on `StatCard.Root` replaces all three at once, from whichever domain the
147
+ consumer is registered under:
148
+
149
+ ```jsx
150
+ <StatCard.Root
151
+ labels={ {
152
+ notApplicable: __( 'Not applicable', 'newspack-manager' ),
153
+ up: __( 'Up', 'newspack-manager' ),
154
+ down: __( 'Down', 'newspack-manager' ),
155
+ } }
156
+ >
157
+ ```
158
+
159
+ A wrapper component that renders the `Root`, which is how both adopters use this,
160
+ sets it once and every card underneath is right. `valueLabel`, `directionLabel`
161
+ and `label` are unchanged: they remain the per-instance override for one figure
162
+ or one change that needs saying differently, and they still win. A blank entry in
163
+ `labels` falls back to the built-in default, so a translation that came back
164
+ empty cannot leave the glyph or the arrow unnamed.
165
+
166
+ ## Anatomy, not policy
167
+
168
+ The component is the chrome, the layout and the type scale. Anything that
169
+ decides *what* to show is the consumer's.
170
+
171
+ That line matters most for Insights, whose `MetricCard` wraps this one and adds
172
+ period-over-period deltas, warming states, "not configured" overlays and
173
+ zero-fallback heroes. None of that belongs here; a rule about a Google Analytics
174
+ property is not a rule about a card. `MetricCard` composes the slots and keeps
175
+ its own props.
176
+
177
+ ## Refs and pass-through props
178
+
179
+ Every part forwards a ref to the element it renders and passes any prop its own
180
+ table does not name straight through to that element, so `id`, `style`, `title`
181
+ and `data-*` all land on the DOM node:
182
+
183
+ | Part | The element it renders |
184
+ |------|------------------------|
185
+ | `Root` | The card |
186
+ | `Label` | The label row, not the heading |
187
+ | `Body` | The body column |
188
+ | `Value` | The figure, not the row it shares with a `suffix` |
189
+ | `Delta` | The delta |
190
+ | `Secondary` | The secondary line |
191
+ | `Footer` | The footer column |
192
+
193
+ That is what lets a wrapper hang the unabbreviated amount off a `$1.2M` without
194
+ wrapping the figure in an element the body layout would then have to carry.
195
+
196
+ ## Class names
197
+
198
+ The component emits these, so a stylesheet can hook onto any of them:
199
+
200
+ | Class | Element |
201
+ |-------|---------|
202
+ | `newspack-stat-card` | The card |
203
+ | `newspack-stat-card__content` | The content column |
204
+ | `newspack-stat-card__label` | The label row |
205
+ | `newspack-stat-card__label-text` | The heading inside that row |
206
+ | `newspack-stat-card__body` | The body column |
207
+ | `newspack-stat-card__figure` | The row the figure shares with a `Value` `suffix`, present only when there is one |
208
+ | `newspack-stat-card__value` | The figure |
209
+ | `newspack-stat-card__delta` | The delta |
210
+ | `newspack-stat-card__secondary` | The secondary line |
211
+ | `newspack-stat-card__footer` | The footer column |
212
+ | `newspack-stat-card__description` | Each paragraph of the description |
213
+
214
+ `newspack-stat-card__action` is the one exception, and it runs the other way: the card
215
+ carries the rule but never applies the class, so an action in `Footer` takes it by hand.
216
+
217
+ ## `StatCard.Root`
218
+
219
+ | Prop | Type | Default | Description |
220
+ |------|------|---------|-------------|
221
+ | `children` | `React.ReactNode` | — | The slots. |
222
+ | `className` | `string` | — | Merged onto the card, alongside `newspack-stat-card`. |
223
+ | `heading` | `2`–`6` | `3` | Heading level for `StatCard.Label`, passed through context. |
224
+ | `labels` | `{ notApplicable, up, down }` | The built-in strings | Spoken defaults for every card underneath. See [Outside `newspack-plugin`](#outside-newspack-plugin). |
225
+
226
+ Renders `Card.Root` / `Card.Content` from `@wordpress/ui`, and owns the
227
+ container query.
228
+
229
+ ## `StatCard.Label`
230
+
231
+ | Prop | Type | Default | Description |
232
+ |------|------|---------|-------------|
233
+ | `children` | `React.ReactNode` | — | The label text. |
234
+ | `className` | `string` | — | Merged onto the label row, not the heading. |
235
+ | `heading` | `2`–`6` | Root's | Overrides the level set on `Root`. |
236
+ | `suffix` | `React.ReactNode` | — | Rendered beside the heading, e.g. an info button. |
237
+
238
+ `suffix` sits next to the heading rather than inside it, so a control there stays
239
+ out of the document outline and off the heading's accessible name. Supplementary
240
+ context belongs in an `InfoButton`, which already carries the popup, the touch
241
+ behaviour and the accessible name; the slot itself takes anything.
242
+
243
+ The card pulls an `InfoButton` in that slot back to the 20px line
244
+ `heading-large()` gives the heading, so a card carrying one and a card without
245
+ still have their figures level. The button makes no assumption about its host, so
246
+ the trim lives here rather than on it.
247
+
248
+ ```jsx
249
+ <StatCard.Label
250
+ suffix={
251
+ <InfoButton
252
+ description={ __( 'Averaged across the timeframe.', 'newspack-plugin' ) }
253
+ triggerLabel={ __( 'More information about Average order value', 'newspack-plugin' ) }
254
+ />
255
+ }
256
+ >
257
+ { __( 'Average order value', 'newspack-plugin' ) }
258
+ </StatCard.Label>
259
+ ```
260
+
261
+ Any other control in that slot has to hold the 20px line itself, or it grows the
262
+ row and drops that card's figure below the rest.
263
+
264
+ A level outside 2–6 falls back to `3` and warns outside production, rather than
265
+ rendering an element that is not a heading at all.
266
+
267
+ ## `StatCard.Body`
268
+
269
+ | Prop | Type | Default | Description |
270
+ |------|------|---------|-------------|
271
+ | `children` | `React.ReactNode` | — | The value, plus anything that belongs with it. |
272
+ | `className` | `string` | — | Merged onto the body. |
273
+
274
+ A column that takes the free space. Put `StatCard.Value` in it, plus a
275
+ `StatCard.Secondary` line or a consumer-owned element such as a delta.
276
+
277
+ ## `StatCard.Value`
278
+
279
+ | Prop | Type | Default | Description |
280
+ |------|------|---------|-------------|
281
+ | `className` | `string` | — | Merged onto the value. |
282
+ | `suffix` | `React.ReactNode` | — | Rendered in a row beside the figure, e.g. a `StatCard.Delta`. |
283
+ | `value` | `string` \| `number` \| `null` \| `undefined` | — | **Required.** Pre-formatted. `null`, `undefined` and a blank string render the null glyph. |
284
+ | `valueLabel` | `string` | Root's `notApplicable` when null | Spoken instead of the visible value. |
285
+ | `variant` | `'figure'` \| `'text'` | `'figure'` | `text` drops the hero scale for a phrase. |
286
+
287
+ With a `suffix`, the figure and the suffix share a baseline-aligned row. Without
288
+ one, the figure renders on its own with no extra wrapper.
289
+
290
+ ## `StatCard.Delta`
291
+
292
+ | Prop | Type | Default | Description |
293
+ |------|------|---------|-------------|
294
+ | `children` | `React.ReactNode` | — | The change, pre-formatted. Must be non-interactive. |
295
+ | `className` | `string` | — | Merged onto the delta. |
296
+ | `direction` | `'up'` \| `'down'` | — | **Required.** Which arrow to show. |
297
+ | `directionLabel` | `string` | Root's `up` or `down` | Spoken in place of the direction. |
298
+ | `label` | `string` | — | Spoken in place of the whole delta. Wins over `directionLabel`. |
299
+ | `tone` | `'positive'` \| `'negative'` \| `'neutral'` | `'neutral'` | Which colour to use. |
300
+
301
+ ```jsx
302
+ <StatCard.Value
303
+ value="1,284"
304
+ suffix={ <StatCard.Delta direction="up" tone="positive">2%</StatCard.Delta> }
305
+ />
306
+ ```
307
+
308
+ **`direction` and `tone` are deliberately separate.** A rise is not always good
309
+ news: a refund rate climbing 2% wants an up arrow and a negative tone. The
310
+ component owns the arrow, the size and the colour; the caller, which is the only
311
+ one that knows what the figure means, decides which of them applies.
312
+
313
+ The arrow is `aria-hidden` and its meaning supplied as text, so the delta reads
314
+ as "Up 2%" rather than as a glyph. That also means the direction survives for
315
+ anyone who cannot use the colour, which the colour alone would not.
316
+
317
+ The tone does not survive it. "Up 2%" reads the same whether the rise is good
318
+ news or bad, because that difference lives only in the colour. Where it matters,
319
+ put it in words: `label` replaces the whole spoken delta, and the arrow and the
320
+ change are hidden behind it.
321
+
322
+ That hiding is why the children must be text rather than a control. Anything
323
+ focusable there would still take tab focus while being hidden from the
324
+ accessibility tree, which is a state a screen-reader user cannot make sense of.
325
+
326
+ ```jsx
327
+ <StatCard.Delta
328
+ direction="up"
329
+ tone="negative"
330
+ label={ sprintf(
331
+ // translators: %s is the change, e.g. "2%".
332
+ __( '%s more refunds than last month', 'newspack-plugin' ),
333
+ '2%'
334
+ ) }
335
+ >
336
+ 2%
337
+ </StatCard.Delta>
338
+ ```
339
+
340
+ A blank `label` or `directionLabel` counts as none, and falls back the way an
341
+ empty one does, so a delta is never left announcing whitespace.
342
+
343
+ `label` is also the way out of the default's word order. "Up 2%" is the arrow's
344
+ text followed by the children, which the markup fixes: a language that wants the
345
+ figure inside the phrase, or the direction after it, cannot get there by swapping
346
+ one word with `directionLabel`. One translatable sentence can.
347
+
348
+ A direction outside `up` and `down` shows no arrow and warns outside production.
349
+ With nothing else to go on it says nothing, rather than naming the opposite
350
+ direction. A `directionLabel` or a `label` is still spoken, because the caller
351
+ chose those words.
352
+
353
+ ## `StatCard.Secondary`
354
+
355
+ | Prop | Type | Default | Description |
356
+ |------|------|---------|-------------|
357
+ | `children` | `React.ReactNode` | — | A short qualifying line under the value. |
358
+ | `className` | `string` | — | Merged onto the line. |
359
+
360
+ It takes the figure's colour and a heading scale, so it reads as part of the
361
+ headline rather than a note under it. The quiet line is the footer's description.
362
+
363
+ ## `StatCard.Footer`
364
+
365
+ | Prop | Type | Default | Description |
366
+ |------|------|---------|-------------|
367
+ | `children` | `React.ReactNode` | — | The description, plus any action. |
368
+ | `className` | `string` | — | Merged onto the footer. |
369
+
370
+ Pinned to the bottom. A run of text children shares one `<p>` carrying the
371
+ description styling, so `<StatCard.Footer>Applies to { count } products</StatCard.Footer>`
372
+ is one sentence rather than three stacked lines; elements pass through
373
+ untouched, which is how an action lands under the text.
374
+
375
+ An element ends the run, so a description with inline markup in the middle of it
376
+ would be split across several blocks. A Fragment does not: `createInterpolateElement`
377
+ returns one, and the sentence inside it stays part of the run, styled like any
378
+ other description.
379
+
380
+ ```jsx
381
+ <StatCard.Footer>
382
+ { createInterpolateElement( __( 'Applies to <b>12</b> products.', 'newspack-plugin' ), {
383
+ b: <strong />,
384
+ } ) }
385
+ </StatCard.Footer>
386
+ ```
387
+
388
+ Anything else you want kept together, wrap yourself and it passes through as one:
389
+
390
+ ```jsx
391
+ <StatCard.Footer>
392
+ <p className="newspack-stat-card__description">
393
+ Applies to <strong>12</strong> products.
394
+ </p>
395
+ </StatCard.Footer>
396
+ ```
397
+
398
+ That rule keys on the wrapper rather than on what it holds, so a Fragment is
399
+ folded into the run whatever is inside it. A group of non-text content, two
400
+ buttons say, needs a real element around it in the same way: inside a Fragment
401
+ both would land in the description `<p>` and take its quiet 12px type, and
402
+ anything rendering a `<div>` there would trip React's nesting warning as well.
403
+
404
+ An action keeps the description's type scale by taking the
405
+ `newspack-stat-card__action` class:
406
+
407
+ ```jsx
408
+ <StatCard.Footer>
409
+ { __( 'Products this rule applies to.', 'newspack-plugin' ) }
410
+ <Button isLink className="newspack-stat-card__action" onClick={ onView }>
411
+ { __( 'See the products', 'newspack-plugin' ) }
412
+ </Button>
413
+ </StatCard.Footer>
414
+ ```
415
+
416
+ ## `STAT_CARD_NULL_GLYPH`
417
+
418
+ The glyph `StatCard.Value` shows for `null`, exported so a table under a row of
419
+ cards can show the same one:
420
+
421
+ ```jsx
422
+ import { STAT_CARD_NULL_GLYPH } from 'newspack-components';
423
+ ```
424
+
425
+ ## Outside the Root
426
+
427
+ Every subcomponent reads Root's context. Outside one it throws "StatCard
428
+ subcomponents must be rendered inside StatCard.Root.", which is what surfaces the
429
+ mistake in development and in tests.
430
+
431
+ A production build warns and falls back to the default context instead. Nothing
432
+ in this package or in `newspack-plugin` puts an error boundary above these cards,
433
+ so throwing there would blank an admin screen; a figure sized against the wrong
434
+ container is the smaller failure of the two.
@@ -0,0 +1,28 @@
1
+ /**
2
+ * WordPress dependencies.
3
+ */
4
+ import { forwardRef } from '@wordpress/element';
5
+ import { Stack } from '@wordpress/ui';
6
+
7
+ /**
8
+ * External dependencies.
9
+ */
10
+ import classnames from 'classnames';
11
+
12
+ /**
13
+ * Internal dependencies.
14
+ */
15
+ import { useStatCardContext } from './context';
16
+ import type { StatCardBodyProps } from './types';
17
+
18
+ const Body = forwardRef< HTMLDivElement, StatCardBodyProps >( function Body( { className, children, ...props }, ref ) {
19
+ useStatCardContext();
20
+
21
+ return (
22
+ <Stack ref={ ref } direction="column" gap="xs" className={ classnames( 'newspack-stat-card__body', className ) } { ...props }>
23
+ { children }
24
+ </Stack>
25
+ );
26
+ } );
27
+
28
+ export default Body;
@@ -0,0 +1,5 @@
1
+ /**
2
+ * Stands in for a figure that does not exist, rather than a zero that does.
3
+ * Exported so tables can show the same glyph as the cards above them.
4
+ */
5
+ export const STAT_CARD_NULL_GLYPH = '—';
@@ -0,0 +1,53 @@
1
+ /**
2
+ * WordPress dependencies.
3
+ */
4
+ import { createContext, useContext, useEffect } from '@wordpress/element';
5
+ import { _x } from '@wordpress/i18n';
6
+
7
+ /**
8
+ * Internal dependencies.
9
+ */
10
+ import type { StatCardHeadingLevel, StatCardLabels } from './types';
11
+
12
+ type StatCardContextValue = {
13
+ heading: StatCardHeadingLevel;
14
+ labels: StatCardLabels;
15
+ };
16
+
17
+ const ORPHAN_MESSAGE = 'StatCard subcomponents must be rendered inside StatCard.Root.';
18
+
19
+ // Resolved per call rather than once at module load, so a locale switched after
20
+ // the bundle evaluates still reaches the defaults. A blank override is a missing
21
+ // one: the glyph and the arrow must never be left announcing nothing.
22
+ export const resolveStatCardLabels = ( labels?: Partial< StatCardLabels > ): StatCardLabels => ( {
23
+ notApplicable: labels?.notApplicable?.trim() || _x( 'Not applicable', 'a statistic with no number to show', 'newspack-plugin' ),
24
+ up: labels?.up?.trim() || _x( 'Up', 'a statistic that has increased', 'newspack-plugin' ),
25
+ down: labels?.down?.trim() || _x( 'Down', 'a statistic that has decreased', 'newspack-plugin' ),
26
+ } );
27
+
28
+ export const StatCardContext = createContext< StatCardContextValue | null >( null );
29
+
30
+ export const useStatCardContext = (): StatCardContextValue => {
31
+ const context = useContext( StatCardContext );
32
+ const isOrphan = ! context;
33
+
34
+ useEffect( () => {
35
+ if ( ! isOrphan ) {
36
+ return;
37
+ }
38
+ // eslint-disable-next-line no-console
39
+ console.warn( ORPHAN_MESSAGE );
40
+ }, [ isOrphan ] );
41
+
42
+ if ( ! context ) {
43
+ // A loose slot sizes its figure against the wrong container, which is
44
+ // cosmetic. Nothing above these cards catches an error, so throwing in
45
+ // production would blank an admin screen over it.
46
+ if ( 'production' !== process.env.NODE_ENV ) {
47
+ throw new Error( ORPHAN_MESSAGE );
48
+ }
49
+ return { heading: 3, labels: resolveStatCardLabels() };
50
+ }
51
+
52
+ return context;
53
+ };
@@ -0,0 +1,88 @@
1
+ /**
2
+ * WordPress dependencies.
3
+ */
4
+ import { forwardRef, useEffect } from '@wordpress/element';
5
+ import { VisuallyHidden } from '@wordpress/ui';
6
+
7
+ /**
8
+ * External dependencies.
9
+ */
10
+ import classnames from 'classnames';
11
+
12
+ /**
13
+ * Internal dependencies.
14
+ */
15
+ import { useStatCardContext } from './context';
16
+ import type { StatCardDeltaDirection, StatCardDeltaProps, StatCardDeltaTone } from './types';
17
+
18
+ const glyphs: Record< StatCardDeltaDirection, string > = {
19
+ up: '↑',
20
+ down: '↓',
21
+ };
22
+
23
+ const tones: StatCardDeltaTone[] = [ 'positive', 'negative', 'neutral' ];
24
+
25
+ const Delta = forwardRef< HTMLSpanElement, StatCardDeltaProps >( function Delta(
26
+ { direction, tone = 'neutral', directionLabel, label, className, children, ...props },
27
+ ref
28
+ ) {
29
+ const { labels } = useStatCardContext();
30
+
31
+ useEffect( () => {
32
+ if ( 'production' === process.env.NODE_ENV ) {
33
+ return;
34
+ }
35
+ if ( ! glyphs[ direction ] ) {
36
+ // eslint-disable-next-line no-console
37
+ console.warn( `StatCard.Delta: unknown direction "${ direction }". Use one of ${ Object.keys( glyphs ).join( ', ' ) }.` );
38
+ }
39
+ if ( ! tones.includes( tone ) ) {
40
+ // eslint-disable-next-line no-console
41
+ console.warn( `StatCard.Delta: unknown tone "${ tone }", falling back to neutral. Use one of ${ tones.join( ', ' ) }.` );
42
+ }
43
+ }, [ direction, tone ] );
44
+
45
+ const directions: Record< StatCardDeltaDirection, string > = {
46
+ up: labels.up,
47
+ down: labels.down,
48
+ };
49
+
50
+ const glyph = glyphs[ direction ];
51
+ // Trimmed, and `||` not `??`: a blank label is a missing one. The fallback
52
+ // is keyed on the arrow's own direction, so an unrecognised one goes
53
+ // unspoken rather than announced as its opposite; words the caller wrote
54
+ // are honoured either way.
55
+ const named = label?.trim();
56
+ const spoken = directionLabel?.trim() || directions[ direction ];
57
+
58
+ const classes = classnames(
59
+ 'newspack-stat-card__delta',
60
+ 'neutral' !== tone && tones.includes( tone ) && `newspack-stat-card__delta--${ tone }`,
61
+ className
62
+ );
63
+
64
+ return (
65
+ <span ref={ ref } className={ classes } { ...props }>
66
+ { named ? (
67
+ <>
68
+ { /* `label` names the whole delta, so the change it restates is hidden with the arrow. */ }
69
+ <span aria-hidden="true">
70
+ { glyph }
71
+ { children }
72
+ </span>
73
+ <VisuallyHidden render={ <span /> }>{ named }</VisuallyHidden>
74
+ </>
75
+ ) : (
76
+ <>
77
+ { /* The arrow is hidden and its meaning given as text, since a bare glyph announces inconsistently. */ }
78
+ { glyph && <span aria-hidden="true">{ glyph }</span> }
79
+ { /* The trailing space separates the direction from the change in the raw text, not only in the layout. */ }
80
+ { spoken && <VisuallyHidden render={ <span /> }>{ `${ spoken } ` }</VisuallyHidden> }
81
+ { children }
82
+ </>
83
+ ) }
84
+ </span>
85
+ );
86
+ } );
87
+
88
+ export default Delta;
@@ -0,0 +1,68 @@
1
+ /**
2
+ * WordPress dependencies.
3
+ */
4
+ import { Children, forwardRef, Fragment, isValidElement } from '@wordpress/element';
5
+ import { Stack } from '@wordpress/ui';
6
+
7
+ /**
8
+ * External dependencies.
9
+ */
10
+ import classnames from 'classnames';
11
+
12
+ /**
13
+ * Internal dependencies.
14
+ */
15
+ import { useStatCardContext } from './context';
16
+ import type { StatCardFooterProps } from './types';
17
+
18
+ // A run of text shares one wrapper, so `Applies to { count } products` is one
19
+ // sentence rather than three stacked paragraphs.
20
+ const asParts = ( children: React.ReactNode ) => {
21
+ const parts: React.ReactNode[] = [];
22
+ let text: React.ReactNode[] = [];
23
+
24
+ const flushText = () => {
25
+ if ( text.some( part => '' !== String( part ).trim() ) ) {
26
+ parts.push(
27
+ <p key={ `text-${ parts.length }` } className="newspack-stat-card__description">
28
+ { text }
29
+ </p>
30
+ );
31
+ }
32
+ text = [];
33
+ };
34
+
35
+ Children.toArray( children ).forEach( child => {
36
+ // A Fragment is part of the run, not a block of its own:
37
+ // `createInterpolateElement` returns one, and the sentence it holds
38
+ // belongs in the description wrapper like any other text.
39
+ if ( isValidElement( child ) && Fragment !== child.type ) {
40
+ flushText();
41
+ parts.push( child );
42
+ } else {
43
+ text.push( child );
44
+ }
45
+ } );
46
+ flushText();
47
+
48
+ return parts;
49
+ };
50
+
51
+ const Footer = forwardRef< HTMLDivElement, StatCardFooterProps >( function Footer( { className, children, ...props }, ref ) {
52
+ useStatCardContext();
53
+
54
+ return (
55
+ <Stack
56
+ ref={ ref }
57
+ direction="column"
58
+ align="flex-start"
59
+ gap="xs"
60
+ className={ classnames( 'newspack-stat-card__footer', className ) }
61
+ { ...props }
62
+ >
63
+ { asParts( children ) }
64
+ </Stack>
65
+ );
66
+ } );
67
+
68
+ export default Footer;