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,818 @@
1
+ /**
2
+ * External dependencies
3
+ */
4
+ import { render, screen } from '@testing-library/react';
5
+
6
+ /**
7
+ * WordPress dependencies
8
+ */
9
+ import { createInterpolateElement } from '@wordpress/element';
10
+
11
+ /**
12
+ * Internal dependencies
13
+ */
14
+ import StatCard, { STAT_CARD_NULL_GLYPH } from '.';
15
+
16
+ const renderOrphan = node => {
17
+ const consoleError = jest.spyOn( console, 'error' ).mockImplementation( () => {} );
18
+ try {
19
+ expect( () => render( node ) ).toThrow( 'StatCard subcomponents must be rendered inside StatCard.Root.' );
20
+ } finally {
21
+ consoleError.mockRestore();
22
+ }
23
+ };
24
+
25
+ describe( 'StatCard.Root', () => {
26
+ // The hero scale is a container query against this class, so losing it
27
+ // silently resizes the figure.
28
+ it( 'carries the class the container query is scoped to', () => {
29
+ const { container } = render(
30
+ <StatCard.Root>
31
+ <p>body</p>
32
+ </StatCard.Root>
33
+ );
34
+ expect( container.querySelector( '.newspack-stat-card' ) ).toBeInTheDocument();
35
+ expect( container.querySelector( '.newspack-stat-card__content' ) ).toBeInTheDocument();
36
+ } );
37
+
38
+ // The footer only pins to the bottom while the content region is a column.
39
+ it( 'lays the content region out as a column', () => {
40
+ const { container } = render(
41
+ <StatCard.Root>
42
+ <p>body</p>
43
+ </StatCard.Root>
44
+ );
45
+ expect( container.querySelector( '.newspack-stat-card__content' ) ).toHaveStyle( { flexDirection: 'column' } );
46
+ } );
47
+
48
+ it( 'merges className onto the card', () => {
49
+ const { container } = render(
50
+ <StatCard.Root className="consumer-tile">
51
+ <p>body</p>
52
+ </StatCard.Root>
53
+ );
54
+ expect( container.querySelector( '.newspack-stat-card' ) ).toHaveClass( 'consumer-tile' );
55
+ } );
56
+
57
+ it( 'renders its children', () => {
58
+ render(
59
+ <StatCard.Root>
60
+ <p>body</p>
61
+ </StatCard.Root>
62
+ );
63
+ expect( screen.getByText( 'body' ) ).toBeInTheDocument();
64
+ } );
65
+
66
+ // A wrapper in another repo needs the node to anchor a popover or measure the tile.
67
+ it( 'forwards a ref and passes other props to the card', () => {
68
+ const ref = { current: null };
69
+ const { container } = render(
70
+ <StatCard.Root ref={ ref } id="tile-1" data-testid="tile">
71
+ <p>body</p>
72
+ </StatCard.Root>
73
+ );
74
+ const card = container.querySelector( '.newspack-stat-card' );
75
+ expect( ref.current ).toBe( card );
76
+ expect( card ).toHaveAttribute( 'id', 'tile-1' );
77
+ expect( card ).toHaveAttribute( 'data-testid', 'tile' );
78
+ } );
79
+ } );
80
+
81
+ describe( 'StatCard.Label', () => {
82
+ it( 'renders an h3 by default', () => {
83
+ render(
84
+ <StatCard.Root>
85
+ <StatCard.Label>Subscribers reached</StatCard.Label>
86
+ </StatCard.Root>
87
+ );
88
+ expect( screen.getByRole( 'heading', { level: 3, name: 'Subscribers reached' } ) ).toBeInTheDocument();
89
+ } );
90
+
91
+ it( 'follows the level set on Root', () => {
92
+ render(
93
+ <StatCard.Root heading={ 4 }>
94
+ <StatCard.Label>Subscribers reached</StatCard.Label>
95
+ </StatCard.Root>
96
+ );
97
+ expect( screen.getByRole( 'heading', { level: 4, name: 'Subscribers reached' } ) ).toBeInTheDocument();
98
+ } );
99
+
100
+ it( 'lets its own heading override Root', () => {
101
+ render(
102
+ <StatCard.Root heading={ 4 }>
103
+ <StatCard.Label heading={ 2 }>Subscribers reached</StatCard.Label>
104
+ </StatCard.Root>
105
+ );
106
+ expect( screen.getByRole( 'heading', { level: 2, name: 'Subscribers reached' } ) ).toBeInTheDocument();
107
+ } );
108
+
109
+ it( 'falls back to h3 for a level outside 2-6', () => {
110
+ const consoleWarn = jest.spyOn( console, 'warn' ).mockImplementation( () => {} );
111
+ render(
112
+ <StatCard.Root>
113
+ <StatCard.Label heading={ 7 }>Subscribers reached</StatCard.Label>
114
+ </StatCard.Root>
115
+ );
116
+ expect( screen.getByRole( 'heading', { level: 3, name: 'Subscribers reached' } ) ).toBeInTheDocument();
117
+ expect( consoleWarn ).toHaveBeenCalled();
118
+ consoleWarn.mockRestore();
119
+ } );
120
+
121
+ // Inside the heading, the control's text would join its accessible name.
122
+ it( 'renders the suffix beside the heading rather than inside it', () => {
123
+ render(
124
+ <StatCard.Root>
125
+ <StatCard.Label suffix={ <button type="button">About this metric</button> }>Subscribers reached</StatCard.Label>
126
+ </StatCard.Root>
127
+ );
128
+ const heading = screen.getByRole( 'heading', { level: 3, name: 'Subscribers reached' } );
129
+ expect( heading ).not.toContainElement( screen.getByRole( 'button', { name: 'About this metric' } ) );
130
+ } );
131
+
132
+ it( 'merges className onto the row, not the heading', () => {
133
+ const { container } = render(
134
+ <StatCard.Root>
135
+ <StatCard.Label className="consumer-label">Subscribers reached</StatCard.Label>
136
+ </StatCard.Root>
137
+ );
138
+ expect( container.querySelector( '.newspack-stat-card__label' ) ).toHaveClass( 'consumer-label' );
139
+ expect( screen.getByRole( 'heading', { level: 3 } ) ).not.toHaveClass( 'consumer-label' );
140
+ } );
141
+
142
+ it( 'throws outside Root', () => {
143
+ renderOrphan( <StatCard.Label>Orphan</StatCard.Label> );
144
+ } );
145
+ } );
146
+
147
+ describe( 'StatCard.Body', () => {
148
+ it( 'renders its children', () => {
149
+ const { container } = render(
150
+ <StatCard.Root>
151
+ <StatCard.Body>
152
+ <p>body</p>
153
+ </StatCard.Body>
154
+ </StatCard.Root>
155
+ );
156
+ expect( container.querySelector( '.newspack-stat-card__body' ) ).toBeInTheDocument();
157
+ expect( screen.getByText( 'body' ) ).toBeInTheDocument();
158
+ } );
159
+
160
+ it( 'merges className onto the body', () => {
161
+ const { container } = render(
162
+ <StatCard.Root>
163
+ <StatCard.Body className="consumer-body">
164
+ <p>body</p>
165
+ </StatCard.Body>
166
+ </StatCard.Root>
167
+ );
168
+ expect( container.querySelector( '.newspack-stat-card__body' ) ).toHaveClass( 'consumer-body' );
169
+ } );
170
+
171
+ it( 'throws outside Root', () => {
172
+ renderOrphan( <StatCard.Body>Orphan</StatCard.Body> );
173
+ } );
174
+ } );
175
+
176
+ describe( 'StatCard.Value', () => {
177
+ it( 'renders a formatted value as-is, with nothing spoken over it', () => {
178
+ const { container } = render(
179
+ <StatCard.Root>
180
+ <StatCard.Value value="1,284" />
181
+ </StatCard.Root>
182
+ );
183
+ expect( screen.getByText( '1,284' ) ).toBeInTheDocument();
184
+ expect( container.querySelector( '[data-visually-hidden]' ) ).not.toBeInTheDocument();
185
+ expect( container.querySelector( '[aria-hidden="true"]' ) ).not.toBeInTheDocument();
186
+ } );
187
+
188
+ it( 'merges className onto the value', () => {
189
+ const { container } = render(
190
+ <StatCard.Root>
191
+ <StatCard.Value value="1,284" className="consumer-value" />
192
+ </StatCard.Root>
193
+ );
194
+ expect( container.querySelector( '.newspack-stat-card__value' ) ).toHaveClass( 'consumer-value' );
195
+ } );
196
+
197
+ // The glyph exists to tell a missing figure from a zero, so a zero has to survive it.
198
+ it( 'renders a zero as the figure it is', () => {
199
+ const { container } = render(
200
+ <StatCard.Root>
201
+ <StatCard.Value value={ 0 } />
202
+ </StatCard.Root>
203
+ );
204
+ expect( screen.getByText( '0' ) ).toBeInTheDocument();
205
+ expect( screen.queryByText( STAT_CARD_NULL_GLYPH ) ).not.toBeInTheDocument();
206
+ expect( container.querySelector( '[data-visually-hidden]' ) ).not.toBeInTheDocument();
207
+ } );
208
+
209
+ it( 'renders the null glyph with an accessible name for a null value', () => {
210
+ render(
211
+ <StatCard.Root>
212
+ <StatCard.Value value={ null } />
213
+ </StatCard.Root>
214
+ );
215
+ expect( screen.getByText( STAT_CARD_NULL_GLYPH ) ).toHaveAttribute( 'aria-hidden', 'true' );
216
+ expect( screen.getByText( 'Not applicable' ) ).toHaveAttribute( 'data-visually-hidden' );
217
+ } );
218
+
219
+ // `value={ data?.count }` before the data arrives must not read as a zero.
220
+ it( 'treats undefined as no figure', () => {
221
+ render(
222
+ <StatCard.Root>
223
+ <StatCard.Value value={ undefined } />
224
+ </StatCard.Root>
225
+ );
226
+ expect( screen.getByText( STAT_CARD_NULL_GLYPH ) ).toHaveAttribute( 'aria-hidden', 'true' );
227
+ expect( screen.getByText( 'Not applicable' ) ).toHaveAttribute( 'data-visually-hidden' );
228
+ } );
229
+
230
+ // A field that reports "no data" as an empty string would otherwise leave a blank hero.
231
+ it( 'treats an empty value as no figure', () => {
232
+ render(
233
+ <StatCard.Root>
234
+ <StatCard.Value value="" />
235
+ </StatCard.Root>
236
+ );
237
+ expect( screen.getByText( STAT_CARD_NULL_GLYPH ) ).toHaveAttribute( 'aria-hidden', 'true' );
238
+ expect( screen.getByText( 'Not applicable' ) ).toHaveAttribute( 'data-visually-hidden' );
239
+ } );
240
+
241
+ // A no-data sentinel padded by the field it came from is still no data.
242
+ it( 'treats a whitespace-only value as no figure', () => {
243
+ render(
244
+ <StatCard.Root>
245
+ <StatCard.Value value=" " />
246
+ </StatCard.Root>
247
+ );
248
+ expect( screen.getByText( STAT_CARD_NULL_GLYPH ) ).toHaveAttribute( 'aria-hidden', 'true' );
249
+ expect( screen.getByText( 'Not applicable' ) ).toHaveAttribute( 'data-visually-hidden' );
250
+ } );
251
+
252
+ it( 'does not expose the glyph as an image', () => {
253
+ render(
254
+ <StatCard.Root>
255
+ <StatCard.Value value={ null } />
256
+ </StatCard.Root>
257
+ );
258
+ expect( screen.queryByRole( 'img' ) ).not.toBeInTheDocument();
259
+ } );
260
+
261
+ it( 'speaks valueLabel instead of the visible value', () => {
262
+ render(
263
+ <StatCard.Root>
264
+ <StatCard.Value value="$1.2M" valueLabel="1.2 million dollars" />
265
+ </StatCard.Root>
266
+ );
267
+ expect( screen.getByText( '$1.2M' ) ).toHaveAttribute( 'aria-hidden', 'true' );
268
+ expect( screen.getByText( '1.2 million dollars' ) ).toHaveAttribute( 'data-visually-hidden' );
269
+ } );
270
+
271
+ it( 'lets valueLabel replace the default name of the null glyph', () => {
272
+ render(
273
+ <StatCard.Root>
274
+ <StatCard.Value value={ null } valueLabel="No conversions in this timeframe" />
275
+ </StatCard.Root>
276
+ );
277
+ expect( screen.getByText( 'No conversions in this timeframe' ) ).toHaveAttribute( 'data-visually-hidden' );
278
+ expect( screen.queryByText( 'Not applicable' ) ).not.toBeInTheDocument();
279
+ } );
280
+
281
+ // A label mapped from an empty field must not leave the glyph unnamed.
282
+ it( 'falls back to the default name when valueLabel is empty', () => {
283
+ render(
284
+ <StatCard.Root>
285
+ <StatCard.Value value={ null } valueLabel="" />
286
+ </StatCard.Root>
287
+ );
288
+ expect( screen.getByText( STAT_CARD_NULL_GLYPH ) ).toHaveAttribute( 'aria-hidden', 'true' );
289
+ expect( screen.getByText( 'Not applicable' ) ).toHaveAttribute( 'data-visually-hidden' );
290
+ } );
291
+
292
+ // Whitespace is truthy, so an unguarded label would hide the figure and name it nothing.
293
+ it( 'falls back to the default name when valueLabel is only whitespace', () => {
294
+ render(
295
+ <StatCard.Root>
296
+ <StatCard.Value value={ null } valueLabel=" " />
297
+ </StatCard.Root>
298
+ );
299
+ expect( screen.getByText( STAT_CARD_NULL_GLYPH ) ).toHaveAttribute( 'aria-hidden', 'true' );
300
+ expect( screen.getByText( 'Not applicable' ) ).toHaveAttribute( 'data-visually-hidden' );
301
+ } );
302
+
303
+ it( 'drops the hero scale for a text variant', () => {
304
+ const { container } = render(
305
+ <StatCard.Root>
306
+ <StatCard.Value value="0 of 17" variant="text" />
307
+ </StatCard.Root>
308
+ );
309
+ expect( container.querySelector( '.newspack-stat-card__value' ) ).toHaveClass( 'newspack-stat-card__value--text' );
310
+ } );
311
+
312
+ it( 'keeps the null treatment in the text variant', () => {
313
+ const { container } = render(
314
+ <StatCard.Root>
315
+ <StatCard.Value value={ null } variant="text" />
316
+ </StatCard.Root>
317
+ );
318
+ expect( container.querySelector( '.newspack-stat-card__value' ) ).toHaveClass( 'newspack-stat-card__value--text' );
319
+ expect( screen.getByText( STAT_CARD_NULL_GLYPH ) ).toHaveAttribute( 'aria-hidden', 'true' );
320
+ expect( screen.getByText( 'Not applicable' ) ).toHaveAttribute( 'data-visually-hidden' );
321
+ } );
322
+
323
+ it( 'warns on an unknown variant and keeps the hero scale', () => {
324
+ const consoleWarn = jest.spyOn( console, 'warn' ).mockImplementation( () => {} );
325
+ const { container } = render(
326
+ <StatCard.Root>
327
+ <StatCard.Value value="1,284" variant="headline" />
328
+ </StatCard.Root>
329
+ );
330
+ expect( container.querySelector( '.newspack-stat-card__value' ) ).not.toHaveClass( 'newspack-stat-card__value--text' );
331
+ expect( consoleWarn ).toHaveBeenCalled();
332
+ consoleWarn.mockRestore();
333
+ } );
334
+
335
+ it( 'keeps the hero scale by default', () => {
336
+ const { container } = render(
337
+ <StatCard.Root>
338
+ <StatCard.Value value="1,284" />
339
+ </StatCard.Root>
340
+ );
341
+ expect( container.querySelector( '.newspack-stat-card__value' ) ).not.toHaveClass( 'newspack-stat-card__value--text' );
342
+ } );
343
+
344
+ it( 'throws outside Root', () => {
345
+ renderOrphan( <StatCard.Value value="1,284" /> );
346
+ } );
347
+ } );
348
+
349
+ describe( 'StatCard.Delta', () => {
350
+ it( 'renders the change with the arrow named in text', () => {
351
+ const { container } = render(
352
+ <StatCard.Root>
353
+ <StatCard.Delta direction="up">2%</StatCard.Delta>
354
+ </StatCard.Root>
355
+ );
356
+ expect( container.querySelector( '[aria-hidden="true"]' ) ).toHaveTextContent( '↑' );
357
+ expect( screen.getByText( 'Up' ) ).toHaveAttribute( 'data-visually-hidden' );
358
+ expect( screen.getByText( '2%' ) ).toBeInTheDocument();
359
+ } );
360
+
361
+ // Hiding the span forms a box that speech linearises; braille and raw text get no such boundary.
362
+ it( 'separates the spoken direction from the change in the raw text', () => {
363
+ const { container } = render(
364
+ <StatCard.Root>
365
+ <StatCard.Delta direction="up">2%</StatCard.Delta>
366
+ </StatCard.Root>
367
+ );
368
+ expect( container.querySelector( '[data-visually-hidden]' ).textContent ).toBe( 'Up ' );
369
+ expect( container.querySelector( '.newspack-stat-card__delta' ).textContent ).toBe( '↑Up 2%' );
370
+ } );
371
+
372
+ it( 'points the arrow down without changing the colour', () => {
373
+ const { container } = render(
374
+ <StatCard.Root>
375
+ <StatCard.Delta direction="down">2%</StatCard.Delta>
376
+ </StatCard.Root>
377
+ );
378
+ expect( container.querySelector( '[aria-hidden="true"]' ) ).toHaveTextContent( '↓' );
379
+ expect( screen.getByText( 'Down' ) ).toHaveAttribute( 'data-visually-hidden' );
380
+ const delta = container.querySelector( '.newspack-stat-card__delta' );
381
+ expect( delta ).not.toHaveClass( 'newspack-stat-card__delta--negative' );
382
+ expect( delta ).not.toHaveClass( 'newspack-stat-card__delta--positive' );
383
+ } );
384
+
385
+ // A rise is not always good news, so the caller sets the tone separately.
386
+ it( 'takes its tone from the caller, not from the direction', () => {
387
+ const { container } = render(
388
+ <StatCard.Root>
389
+ <StatCard.Delta direction="up" tone="negative">
390
+ 2%
391
+ </StatCard.Delta>
392
+ </StatCard.Root>
393
+ );
394
+ expect( container.querySelector( '.newspack-stat-card__delta' ) ).toHaveClass( 'newspack-stat-card__delta--negative' );
395
+ } );
396
+
397
+ // Three sources compose here: the base class, the tone modifier and the consumer's.
398
+ it( 'merges className onto the delta alongside the tone', () => {
399
+ const { container } = render(
400
+ <StatCard.Root>
401
+ <StatCard.Delta direction="down" tone="negative" className="custom-delta">
402
+ 2%
403
+ </StatCard.Delta>
404
+ </StatCard.Root>
405
+ );
406
+ const delta = container.querySelector( '.newspack-stat-card__delta' );
407
+ expect( delta ).toHaveClass( 'newspack-stat-card__delta--negative' );
408
+ expect( delta ).toHaveClass( 'custom-delta' );
409
+ } );
410
+
411
+ it( 'lets directionLabel replace the spoken direction', () => {
412
+ render(
413
+ <StatCard.Root>
414
+ <StatCard.Delta direction="up" directionLabel="Increased by">
415
+ 2%
416
+ </StatCard.Delta>
417
+ </StatCard.Root>
418
+ );
419
+ expect( screen.getByText( 'Increased by' ) ).toHaveAttribute( 'data-visually-hidden' );
420
+ expect( screen.queryByText( 'Up' ) ).not.toBeInTheDocument();
421
+ } );
422
+
423
+ // No arrow and the word "Down" would be worse than saying nothing at all.
424
+ it( 'leaves an unrecognised direction unspoken', () => {
425
+ const consoleWarn = jest.spyOn( console, 'warn' ).mockImplementation( () => {} );
426
+ const { container } = render(
427
+ <StatCard.Root>
428
+ <StatCard.Delta direction="sideways">2%</StatCard.Delta>
429
+ </StatCard.Root>
430
+ );
431
+ expect( container.querySelector( '[aria-hidden="true"]' ) ).not.toBeInTheDocument();
432
+ expect( container.querySelector( '[data-visually-hidden]' ) ).not.toBeInTheDocument();
433
+ expect( screen.getByText( '2%' ) ).toBeInTheDocument();
434
+ expect( consoleWarn ).toHaveBeenCalled();
435
+ consoleWarn.mockRestore();
436
+ } );
437
+
438
+ // A label mapped from an empty field must not leave the arrow unnamed.
439
+ // The caller chose the words, so a missing arrow must not silence them.
440
+ it( 'speaks a directionLabel where there is no arrow to name', () => {
441
+ const consoleWarn = jest.spyOn( console, 'warn' ).mockImplementation( () => {} );
442
+ const { container } = render(
443
+ <StatCard.Root>
444
+ <StatCard.Delta direction="sideways" directionLabel="Increased by">
445
+ 2%
446
+ </StatCard.Delta>
447
+ </StatCard.Root>
448
+ );
449
+ expect( container.querySelector( '[aria-hidden="true"]' ) ).not.toBeInTheDocument();
450
+ expect( screen.getByText( 'Increased by' ) ).toHaveAttribute( 'data-visually-hidden' );
451
+ consoleWarn.mockRestore();
452
+ } );
453
+
454
+ it( 'falls back to the spoken direction when directionLabel is empty', () => {
455
+ render(
456
+ <StatCard.Root>
457
+ <StatCard.Delta direction="up" directionLabel="">
458
+ 2%
459
+ </StatCard.Delta>
460
+ </StatCard.Root>
461
+ );
462
+ expect( screen.getByText( 'Up' ) ).toHaveAttribute( 'data-visually-hidden' );
463
+ } );
464
+
465
+ it( 'falls back to the spoken direction when directionLabel is only whitespace', () => {
466
+ render(
467
+ <StatCard.Root>
468
+ <StatCard.Delta direction="up" directionLabel=" ">
469
+ 2%
470
+ </StatCard.Delta>
471
+ </StatCard.Root>
472
+ );
473
+ expect( screen.getByText( 'Up' ) ).toHaveAttribute( 'data-visually-hidden' );
474
+ } );
475
+
476
+ // Word order and valence both live in the caller's sentence, not in DOM order.
477
+ it( 'lets label name the whole delta and hides what it restates', () => {
478
+ const { container } = render(
479
+ <StatCard.Root>
480
+ <StatCard.Delta direction="up" tone="negative" label="2% more refunds than last month">
481
+ 2%
482
+ </StatCard.Delta>
483
+ </StatCard.Root>
484
+ );
485
+ expect( container.querySelector( '[aria-hidden="true"]' ) ).toHaveTextContent( '↑2%' );
486
+ expect( screen.getByText( '2% more refunds than last month' ) ).toHaveAttribute( 'data-visually-hidden' );
487
+ expect( screen.queryByText( 'Up' ) ).not.toBeInTheDocument();
488
+ } );
489
+
490
+ // The whole-delta sentence outranks the one-word swap, which only prose said until now.
491
+ it( 'lets label win over directionLabel', () => {
492
+ render(
493
+ <StatCard.Root>
494
+ <StatCard.Delta direction="up" label="2% more refunds than last month" directionLabel="Increased by">
495
+ 2%
496
+ </StatCard.Delta>
497
+ </StatCard.Root>
498
+ );
499
+ expect( screen.getByText( '2% more refunds than last month' ) ).toHaveAttribute( 'data-visually-hidden' );
500
+ expect( screen.queryByText( 'Increased by' ) ).not.toBeInTheDocument();
501
+ expect( screen.queryByText( 'Up' ) ).not.toBeInTheDocument();
502
+ } );
503
+
504
+ // Falling back past a blank label should land on the caller's word, not the built-in one.
505
+ it( 'falls back from a blank label to directionLabel rather than the default', () => {
506
+ render(
507
+ <StatCard.Root>
508
+ <StatCard.Delta direction="up" label=" " directionLabel="Increased by">
509
+ 2%
510
+ </StatCard.Delta>
511
+ </StatCard.Root>
512
+ );
513
+ expect( screen.getByText( 'Increased by' ) ).toHaveAttribute( 'data-visually-hidden' );
514
+ expect( screen.queryByText( 'Up' ) ).not.toBeInTheDocument();
515
+ } );
516
+
517
+ // A blank sentence would hide the arrow and the change behind nothing at all.
518
+ it( 'keeps the arrow and the change when label is only whitespace', () => {
519
+ const { container } = render(
520
+ <StatCard.Root>
521
+ <StatCard.Delta direction="up" label=" ">
522
+ 2%
523
+ </StatCard.Delta>
524
+ </StatCard.Root>
525
+ );
526
+ expect( container.querySelector( '[aria-hidden="true"]' ) ).toHaveTextContent( '↑' );
527
+ expect( screen.getByText( 'Up' ) ).toHaveAttribute( 'data-visually-hidden' );
528
+ expect( screen.getByText( '2%' ) ).toBeInTheDocument();
529
+ } );
530
+
531
+ it( 'sits in a row beside the figure when passed as a Value suffix', () => {
532
+ const { container } = render(
533
+ <StatCard.Root>
534
+ <StatCard.Value
535
+ value="1,284"
536
+ suffix={
537
+ <StatCard.Delta direction="up" tone="positive">
538
+ 2%
539
+ </StatCard.Delta>
540
+ }
541
+ />
542
+ </StatCard.Root>
543
+ );
544
+ const figure = container.querySelector( '.newspack-stat-card__figure' );
545
+ expect( figure ).toHaveStyle( { flexDirection: 'row' } );
546
+ expect( figure.querySelector( '.newspack-stat-card__value' ) ).toBeInTheDocument();
547
+ expect( figure.querySelector( '.newspack-stat-card__delta' ) ).toBeInTheDocument();
548
+ } );
549
+
550
+ it( 'adds no row when the value has no suffix', () => {
551
+ const { container } = render(
552
+ <StatCard.Root>
553
+ <StatCard.Value value="1,284" />
554
+ </StatCard.Root>
555
+ );
556
+ expect( container.querySelector( '.newspack-stat-card__figure' ) ).not.toBeInTheDocument();
557
+ } );
558
+
559
+ it( 'throws outside Root', () => {
560
+ renderOrphan( <StatCard.Delta direction="up">2%</StatCard.Delta> );
561
+ } );
562
+ } );
563
+
564
+ describe( 'StatCard.Secondary', () => {
565
+ it( 'renders its children', () => {
566
+ const { container } = render(
567
+ <StatCard.Root>
568
+ <StatCard.Secondary>Up from 1,190 last month</StatCard.Secondary>
569
+ </StatCard.Root>
570
+ );
571
+ expect( container.querySelector( '.newspack-stat-card__secondary' ) ).toBeInTheDocument();
572
+ expect( screen.getByText( 'Up from 1,190 last month' ) ).toBeInTheDocument();
573
+ } );
574
+
575
+ it( 'merges className onto the line', () => {
576
+ const { container } = render(
577
+ <StatCard.Root>
578
+ <StatCard.Secondary className="consumer-secondary">Up from 1,190 last month</StatCard.Secondary>
579
+ </StatCard.Root>
580
+ );
581
+ expect( container.querySelector( '.newspack-stat-card__secondary' ) ).toHaveClass( 'consumer-secondary' );
582
+ } );
583
+
584
+ it( 'throws outside Root', () => {
585
+ renderOrphan( <StatCard.Secondary>Orphan</StatCard.Secondary> );
586
+ } );
587
+ } );
588
+
589
+ describe( 'StatCard.Footer', () => {
590
+ it( 'wraps a bare description in the description styling', () => {
591
+ const { container } = render(
592
+ <StatCard.Root>
593
+ <StatCard.Footer>Readers who received at least one campaign.</StatCard.Footer>
594
+ </StatCard.Root>
595
+ );
596
+ const description = container.querySelector( '.newspack-stat-card__description' );
597
+ expect( description.tagName ).toBe( 'P' );
598
+ expect( description ).toHaveTextContent( 'Readers who received at least one campaign.' );
599
+ } );
600
+
601
+ it( 'merges className onto the footer', () => {
602
+ const { container } = render(
603
+ <StatCard.Root>
604
+ <StatCard.Footer className="consumer-footer">Readers who received at least one campaign.</StatCard.Footer>
605
+ </StatCard.Root>
606
+ );
607
+ expect( container.querySelector( '.newspack-stat-card__footer' ) ).toHaveClass( 'consumer-footer' );
608
+ } );
609
+
610
+ // An interpolated sentence arrives as several children and has to stay one sentence.
611
+ it( 'keeps a run of text children in one paragraph', () => {
612
+ const count = 12;
613
+ const { container } = render(
614
+ <StatCard.Root>
615
+ <StatCard.Footer>Applies to { count } products.</StatCard.Footer>
616
+ </StatCard.Root>
617
+ );
618
+ const descriptions = container.querySelectorAll( '.newspack-stat-card__description' );
619
+ expect( descriptions ).toHaveLength( 1 );
620
+ expect( descriptions[ 0 ] ).toHaveTextContent( 'Applies to 12 products.' );
621
+ } );
622
+
623
+ it( 'passes elements through untouched', () => {
624
+ const { container } = render(
625
+ <StatCard.Root>
626
+ <StatCard.Footer>
627
+ <button type="button">See the products</button>
628
+ </StatCard.Footer>
629
+ </StatCard.Root>
630
+ );
631
+ expect( container.querySelector( '.newspack-stat-card__description' ) ).not.toBeInTheDocument();
632
+ expect( screen.getByRole( 'button', { name: 'See the products' } ) ).toBeInTheDocument();
633
+ } );
634
+
635
+ it( 'wraps only the text when an action sits beside it', () => {
636
+ const { container } = render(
637
+ <StatCard.Root>
638
+ <StatCard.Footer>
639
+ { 'Products this rule applies to.' }
640
+ <button type="button">See the products</button>
641
+ </StatCard.Footer>
642
+ </StatCard.Root>
643
+ );
644
+ expect( container.querySelectorAll( '.newspack-stat-card__description' ) ).toHaveLength( 1 );
645
+ expect( screen.getByText( 'Products this rule applies to.' ) ).toHaveClass( 'newspack-stat-card__description' );
646
+ expect( screen.getByRole( 'button', { name: 'See the products' } ) ).toBeInTheDocument();
647
+ } );
648
+
649
+ // An empty or whitespace-only child would otherwise leave a stray paragraph.
650
+ it( 'renders nothing for an empty description', () => {
651
+ const { container } = render(
652
+ <StatCard.Root>
653
+ <StatCard.Footer>{ '' }</StatCard.Footer>
654
+ </StatCard.Root>
655
+ );
656
+ expect( container.querySelector( '.newspack-stat-card__description' ) ).not.toBeInTheDocument();
657
+ } );
658
+
659
+ it( 'renders nothing for a whitespace-only description', () => {
660
+ const { container } = render(
661
+ <StatCard.Root>
662
+ <StatCard.Footer>{ ' ' }</StatCard.Footer>
663
+ </StatCard.Root>
664
+ );
665
+ expect( container.querySelector( '.newspack-stat-card__description' ) ).not.toBeInTheDocument();
666
+ } );
667
+
668
+ // `createInterpolateElement` returns a Fragment, which has to stay part of the run.
669
+ it( 'keeps an interpolated sentence in the description styling', () => {
670
+ const { container } = render(
671
+ <StatCard.Root>
672
+ <StatCard.Footer>{ createInterpolateElement( 'Applies to <b>12</b> products.', { b: <strong /> } ) }</StatCard.Footer>
673
+ </StatCard.Root>
674
+ );
675
+ const descriptions = container.querySelectorAll( '.newspack-stat-card__description' );
676
+ expect( descriptions ).toHaveLength( 1 );
677
+ expect( descriptions[ 0 ] ).toHaveTextContent( 'Applies to 12 products.' );
678
+ expect( descriptions[ 0 ].querySelector( 'strong' ) ).toBeInTheDocument();
679
+ } );
680
+
681
+ // The documented escape hatch: inline markup would otherwise split into a block per child.
682
+ it( 'passes a self-wrapped description through as one paragraph', () => {
683
+ const { container } = render(
684
+ <StatCard.Root>
685
+ <StatCard.Footer>
686
+ <p className="newspack-stat-card__description">
687
+ Applies to <strong>12</strong> products.
688
+ </p>
689
+ </StatCard.Footer>
690
+ </StatCard.Root>
691
+ );
692
+ const descriptions = container.querySelectorAll( '.newspack-stat-card__description' );
693
+ expect( descriptions ).toHaveLength( 1 );
694
+ expect( descriptions[ 0 ] ).toHaveTextContent( 'Applies to 12 products.' );
695
+ } );
696
+
697
+ it( 'throws outside Root', () => {
698
+ renderOrphan( <StatCard.Footer>Orphan</StatCard.Footer> );
699
+ } );
700
+ } );
701
+
702
+ describe( 'StatCard leaf slots', () => {
703
+ const leaves = [
704
+ [ 'Label', props => <StatCard.Label { ...props }>Reach</StatCard.Label>, '.newspack-stat-card__label' ],
705
+ [ 'Body', props => <StatCard.Body { ...props } />, '.newspack-stat-card__body' ],
706
+ [ 'Value', props => <StatCard.Value value="1,284" { ...props } />, '.newspack-stat-card__value' ],
707
+ [
708
+ 'Delta',
709
+ props => (
710
+ <StatCard.Delta direction="up" { ...props }>
711
+ 2%
712
+ </StatCard.Delta>
713
+ ),
714
+ '.newspack-stat-card__delta',
715
+ ],
716
+ [ 'Secondary', props => <StatCard.Secondary { ...props } />, '.newspack-stat-card__secondary' ],
717
+ [ 'Footer', props => <StatCard.Footer { ...props }>Note</StatCard.Footer>, '.newspack-stat-card__footer' ],
718
+ ];
719
+
720
+ // Insights hangs a full amount off an abbreviated figure, which has nowhere
721
+ // else to go without adding an element the body layout would have to carry.
722
+ it.each( leaves )( '%s forwards a ref and passes other props through', ( name, renderLeaf, selector ) => {
723
+ const ref = { current: null };
724
+ const { container } = render( <StatCard.Root>{ renderLeaf( { ref, title: 'hint', 'data-testid': 'leaf' } ) }</StatCard.Root> );
725
+ const element = container.querySelector( selector );
726
+ expect( ref.current ).toBe( element );
727
+ expect( element ).toHaveAttribute( 'title', 'hint' );
728
+ expect( element ).toHaveAttribute( 'data-testid', 'leaf' );
729
+ } );
730
+
731
+ // The figure is what a wrapper needs to reach, not the row it may share.
732
+ it( 'puts a Value ref on the figure rather than the row it shares with a suffix', () => {
733
+ const ref = { current: null };
734
+ const { container } = render(
735
+ <StatCard.Root>
736
+ <StatCard.Value ref={ ref } value="1,284" suffix={ <StatCard.Delta direction="up">2%</StatCard.Delta> } />
737
+ </StatCard.Root>
738
+ );
739
+ expect( ref.current ).toBe( container.querySelector( '.newspack-stat-card__value' ) );
740
+ } );
741
+ } );
742
+
743
+ describe( 'StatCard outside Root', () => {
744
+ // Nothing above these cards catches an error, so a mistake in a branch only
745
+ // some sites reach must not blank the whole screen.
746
+ it( 'falls back to the default context in production rather than throwing', () => {
747
+ const consoleWarn = jest.spyOn( console, 'warn' ).mockImplementation( () => {} );
748
+ const previous = process.env.NODE_ENV;
749
+ process.env.NODE_ENV = 'production';
750
+
751
+ try {
752
+ render(
753
+ <>
754
+ <StatCard.Label>Reach</StatCard.Label>
755
+ <StatCard.Value value="1,284" />
756
+ </>
757
+ );
758
+ expect( screen.getByRole( 'heading', { level: 3 } ) ).toHaveTextContent( 'Reach' );
759
+ expect( screen.getByText( '1,284' ) ).toBeInTheDocument();
760
+ expect( consoleWarn ).toHaveBeenCalledWith( 'StatCard subcomponents must be rendered inside StatCard.Root.' );
761
+ } finally {
762
+ process.env.NODE_ENV = previous;
763
+ consoleWarn.mockRestore();
764
+ }
765
+ } );
766
+ } );
767
+
768
+ describe( 'StatCard spoken labels', () => {
769
+ // A bundle registered against another text domain never loads this package's strings.
770
+ it( 'lets Root replace the name of the null glyph', () => {
771
+ render(
772
+ <StatCard.Root labels={ { notApplicable: 'Sans objet' } }>
773
+ <StatCard.Value value={ null } />
774
+ </StatCard.Root>
775
+ );
776
+ expect( screen.getByText( 'Sans objet' ) ).toHaveAttribute( 'data-visually-hidden' );
777
+ expect( screen.queryByText( 'Not applicable' ) ).not.toBeInTheDocument();
778
+ } );
779
+
780
+ it( 'lets Root replace the spoken directions', () => {
781
+ render(
782
+ <StatCard.Root labels={ { up: 'En hausse', down: 'En baisse' } }>
783
+ <StatCard.Delta direction="up">2%</StatCard.Delta>
784
+ <StatCard.Delta direction="down">4%</StatCard.Delta>
785
+ </StatCard.Root>
786
+ );
787
+ expect( screen.getByText( 'En hausse' ) ).toHaveAttribute( 'data-visually-hidden' );
788
+ expect( screen.getByText( 'En baisse' ) ).toHaveAttribute( 'data-visually-hidden' );
789
+ } );
790
+
791
+ // The per-instance props were always the override, and stay it.
792
+ it( 'lets valueLabel and directionLabel win over Root labels', () => {
793
+ render(
794
+ <StatCard.Root labels={ { notApplicable: 'Sans objet', up: 'En hausse' } }>
795
+ <StatCard.Value value={ null } valueLabel="Pas encore mesuré" />
796
+ <StatCard.Delta direction="up" directionLabel="Progression de">
797
+ 2%
798
+ </StatCard.Delta>
799
+ </StatCard.Root>
800
+ );
801
+ expect( screen.getByText( 'Pas encore mesuré' ) ).toHaveAttribute( 'data-visually-hidden' );
802
+ expect( screen.getByText( 'Progression de' ) ).toHaveAttribute( 'data-visually-hidden' );
803
+ expect( screen.queryByText( 'Sans objet' ) ).not.toBeInTheDocument();
804
+ expect( screen.queryByText( 'En hausse' ) ).not.toBeInTheDocument();
805
+ } );
806
+
807
+ // A translation that came back empty must not leave the glyph or the arrow unnamed.
808
+ it( 'falls back to the built-in default for a blank Root label', () => {
809
+ render(
810
+ <StatCard.Root labels={ { notApplicable: ' ', up: '' } }>
811
+ <StatCard.Value value={ null } />
812
+ <StatCard.Delta direction="up">2%</StatCard.Delta>
813
+ </StatCard.Root>
814
+ );
815
+ expect( screen.getByText( 'Not applicable' ) ).toHaveAttribute( 'data-visually-hidden' );
816
+ expect( screen.getByText( 'Up' ) ).toHaveAttribute( 'data-visually-hidden' );
817
+ } );
818
+ } );