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,168 @@
1
+ /**
2
+ * External dependencies
3
+ */
4
+ import { fireEvent, render, screen } from '@testing-library/react';
5
+
6
+ /**
7
+ * Internal dependencies
8
+ */
9
+ import CollapsibleGroup from './index';
10
+
11
+ const renderItems = ( count, props = {} ) =>
12
+ render(
13
+ <CollapsibleGroup { ...props }>
14
+ { Array.from( { length: count }, ( _, i ) => (
15
+ <CollapsibleGroup.Item key={ i } title={ `Panel ${ i }` }>
16
+ content
17
+ </CollapsibleGroup.Item>
18
+ ) ) }
19
+ </CollapsibleGroup>
20
+ );
21
+
22
+ const dividers = container => container.querySelectorAll( '.newspack-divider' );
23
+ const items = container => container.querySelectorAll( '.newspack-collapsible-group__item' );
24
+
25
+ describe( 'CollapsibleGroup dividers', () => {
26
+ it( 'renders no divider for a single item', () => {
27
+ const { container } = renderItems( 1 );
28
+ expect( items( container ) ).toHaveLength( 1 );
29
+ expect( dividers( container ) ).toHaveLength( 0 );
30
+ } );
31
+
32
+ it( 'renders a divider between items but not after the last', () => {
33
+ const { container } = renderItems( 3 );
34
+ expect( items( container ) ).toHaveLength( 3 );
35
+ expect( dividers( container ) ).toHaveLength( 2 );
36
+ expect( container.querySelector( '.newspack-collapsible-group' ).lastElementChild ).not.toHaveClass( 'newspack-divider' );
37
+ } );
38
+
39
+ it( 'counts only rendered items when placing dividers', () => {
40
+ const { container } = render(
41
+ <CollapsibleGroup>
42
+ <CollapsibleGroup.Item title="Panel 0">content</CollapsibleGroup.Item>
43
+ <CollapsibleGroup.Item title="Panel 1">content</CollapsibleGroup.Item>
44
+ { false }
45
+ { 'trailing text' }
46
+ </CollapsibleGroup>
47
+ );
48
+
49
+ expect( items( container ) ).toHaveLength( 2 );
50
+ expect( dividers( container ) ).toHaveLength( 1 );
51
+ expect( container.querySelector( '.newspack-collapsible-group' ).lastElementChild ).not.toHaveClass( 'newspack-divider' );
52
+ } );
53
+
54
+ it( 'renders tertiary dividers', () => {
55
+ const { container } = renderItems( 2 );
56
+ expect( dividers( container )[ 0 ] ).toHaveClass( 'newspack-divider--variant-tertiary' );
57
+ } );
58
+ } );
59
+
60
+ describe( 'CollapsibleGroup titleLevel', () => {
61
+ it( 'renders item titles as h2 by default', () => {
62
+ renderItems( 2 );
63
+ expect( screen.getAllByRole( 'heading', { level: 2 } ) ).toHaveLength( 2 );
64
+ } );
65
+
66
+ it( 'renders every item title at the level given', () => {
67
+ renderItems( 2, { titleLevel: 3 } );
68
+ expect( screen.getAllByRole( 'heading', { level: 3 } ) ).toHaveLength( 2 );
69
+ expect( screen.queryByRole( 'heading', { level: 2 } ) ).not.toBeInTheDocument();
70
+ } );
71
+
72
+ it( 'clamps a level above the heading range', () => {
73
+ renderItems( 2, { titleLevel: 7 } );
74
+ expect( screen.getAllByRole( 'heading', { level: 6 } ) ).toHaveLength( 2 );
75
+ } );
76
+
77
+ it( 'clamps a level below the heading range', () => {
78
+ renderItems( 1, { titleLevel: 0 } );
79
+ expect( screen.getByRole( 'heading', { level: 1 } ) ).toBeInTheDocument();
80
+ } );
81
+
82
+ it( 'inherits the level in a nested group', () => {
83
+ render(
84
+ <CollapsibleGroup titleLevel={ 4 }>
85
+ <CollapsibleGroup.Item title="Outer" defaultOpen>
86
+ <CollapsibleGroup>
87
+ <CollapsibleGroup.Item title="Inner">content</CollapsibleGroup.Item>
88
+ </CollapsibleGroup>
89
+ </CollapsibleGroup.Item>
90
+ </CollapsibleGroup>
91
+ );
92
+ expect( screen.getByRole( 'heading', { level: 4, name: 'Inner' } ) ).toBeInTheDocument();
93
+ } );
94
+
95
+ it( 'inherits the level in a nested group when the title is hidden', () => {
96
+ render(
97
+ <CollapsibleGroup titleLevel={ 4 } hideSingleTitle>
98
+ <CollapsibleGroup.Item title="Outer">
99
+ <CollapsibleGroup>
100
+ <CollapsibleGroup.Item title="Inner">content</CollapsibleGroup.Item>
101
+ </CollapsibleGroup>
102
+ </CollapsibleGroup.Item>
103
+ </CollapsibleGroup>
104
+ );
105
+ expect( screen.getByRole( 'heading', { level: 4, name: 'Inner' } ) ).toBeInTheDocument();
106
+ } );
107
+ } );
108
+
109
+ describe( 'CollapsibleGroup hideSingleTitle', () => {
110
+ it( 'keeps the title on a lone item by default', () => {
111
+ renderItems( 1 );
112
+ expect( screen.getByRole( 'button', { name: 'Panel 0' } ) ).toBeInTheDocument();
113
+ } );
114
+
115
+ it( 'drops the title and opens a lone item when set', () => {
116
+ renderItems( 1, { hideSingleTitle: true } );
117
+ expect( screen.queryByRole( 'button', { name: 'Panel 0' } ) ).not.toBeInTheDocument();
118
+ expect( screen.getByText( 'content' ) ).toBeVisible();
119
+ } );
120
+
121
+ it( 'leaves titles alone when there is more than one item', () => {
122
+ renderItems( 2, { hideSingleTitle: true } );
123
+ expect( screen.getByRole( 'button', { name: 'Panel 0' } ) ).toBeInTheDocument();
124
+ expect( screen.getByRole( 'button', { name: 'Panel 1' } ) ).toBeInTheDocument();
125
+ } );
126
+ } );
127
+
128
+ describe( 'CollapsibleGroup item state', () => {
129
+ it( 'expands only the item marked defaultOpen', () => {
130
+ render(
131
+ <CollapsibleGroup>
132
+ <CollapsibleGroup.Item title="Open" defaultOpen>
133
+ open content
134
+ </CollapsibleGroup.Item>
135
+ <CollapsibleGroup.Item title="Closed">closed content</CollapsibleGroup.Item>
136
+ </CollapsibleGroup>
137
+ );
138
+ expect( screen.getByRole( 'button', { name: 'Open' } ) ).toHaveAttribute( 'aria-expanded', 'true' );
139
+ expect( screen.getByRole( 'button', { name: 'Closed' } ) ).toHaveAttribute( 'aria-expanded', 'false' );
140
+ } );
141
+
142
+ it( 'leaves a collapsed panel reachable by find-in-page', () => {
143
+ const { container } = renderItems( 1 );
144
+ expect( container.querySelector( '.newspack-collapsible-group__panel' ) ).toHaveAttribute( 'hidden', 'until-found' );
145
+ expect( screen.getByText( 'content' ) ).toBeInTheDocument();
146
+ } );
147
+
148
+ it( 'opens an item when its trigger is clicked', () => {
149
+ const { container } = renderItems( 1 );
150
+ const trigger = screen.getByRole( 'button', { name: 'Panel 0' } );
151
+
152
+ fireEvent.click( trigger );
153
+
154
+ expect( trigger ).toHaveAttribute( 'aria-expanded', 'true' );
155
+ expect( container.querySelector( '.newspack-collapsible-group__panel' ) ).not.toHaveAttribute( 'hidden' );
156
+ } );
157
+
158
+ it( 'closes an open item when its trigger is clicked', () => {
159
+ const { container } = renderItems( 1 );
160
+ const trigger = screen.getByRole( 'button', { name: 'Panel 0' } );
161
+
162
+ fireEvent.click( trigger );
163
+ fireEvent.click( trigger );
164
+
165
+ expect( trigger ).toHaveAttribute( 'aria-expanded', 'false' );
166
+ expect( container.querySelector( '.newspack-collapsible-group__panel' ) ).toHaveAttribute( 'hidden', 'until-found' );
167
+ } );
168
+ } );
@@ -0,0 +1,17 @@
1
+ /**
2
+ * CollapsibleGroup
3
+ */
4
+
5
+ /**
6
+ * Internal dependencies
7
+ */
8
+ import Item from './item';
9
+ import Root from './root';
10
+
11
+ // The group is itself renderable, so `Item` hangs off it rather than forming a namespace object like `Drawer`.
12
+ const CollapsibleGroup = Object.assign( Root, { Item } );
13
+
14
+ ( CollapsibleGroup as { displayName?: string } ).displayName = 'CollapsibleGroup';
15
+ ( Item as { displayName?: string } ).displayName = 'CollapsibleGroup.Item';
16
+
17
+ export default CollapsibleGroup;
@@ -0,0 +1,42 @@
1
+ /**
2
+ * WordPress dependencies
3
+ */
4
+ import { Icon, chevronDown } from '@wordpress/icons';
5
+ import { Collapsible } from '@wordpress/ui';
6
+
7
+ /**
8
+ * External dependencies
9
+ */
10
+ import classNames from 'classnames';
11
+
12
+ /**
13
+ * Internal dependencies
14
+ */
15
+ import { useTitleLevel } from './context';
16
+ import type { CollapsibleGroupItemProps } from './types';
17
+
18
+ const Item = ( { children, className, defaultOpen = false, title }: CollapsibleGroupItemProps ) => {
19
+ const titleLevel = useTitleLevel();
20
+
21
+ if ( ! title ) {
22
+ return <div className={ classNames( 'newspack-collapsible-group__item', className ) }>{ children }</div>;
23
+ }
24
+
25
+ const Heading = `h${ titleLevel }` as const;
26
+
27
+ return (
28
+ <Collapsible.Root className={ classNames( 'newspack-collapsible-group__item', className ) } defaultOpen={ defaultOpen }>
29
+ <Heading className="newspack-collapsible-group__heading">
30
+ <Collapsible.Trigger className="newspack-collapsible-group__trigger">
31
+ { title }
32
+ <Icon icon={ chevronDown } size={ 24 } />
33
+ </Collapsible.Trigger>
34
+ </Heading>
35
+ <Collapsible.Panel className="newspack-collapsible-group__panel" hiddenUntilFound>
36
+ <div className="newspack-collapsible-group__panel-inner">{ children }</div>
37
+ </Collapsible.Panel>
38
+ </Collapsible.Root>
39
+ );
40
+ };
41
+
42
+ export default Item;
@@ -0,0 +1,42 @@
1
+ /**
2
+ * WordPress dependencies
3
+ */
4
+ import { Children, Fragment, cloneElement, 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 Divider from '../divider';
16
+ import { TitleLevelContext, useTitleLevel } from './context';
17
+ import type { CollapsibleGroupItemProps, CollapsibleGroupProps, HeadingLevel } from './types';
18
+ import './style.scss';
19
+
20
+ const clampTitleLevel = ( level: unknown ): HeadingLevel | undefined =>
21
+ Number.isFinite( level ) ? ( Math.min( 6, Math.max( 1, Math.round( level as number ) ) ) as HeadingLevel ) : undefined;
22
+
23
+ const Root = ( { children, className, gap = 'xl', hideSingleTitle = false, titleLevel }: CollapsibleGroupProps ) => {
24
+ const inheritedTitleLevel = useTitleLevel();
25
+ const items = Children.toArray( children ).filter( isValidElement ) as React.ReactElement< CollapsibleGroupItemProps >[];
26
+ const hideTitle = hideSingleTitle && items.length === 1;
27
+
28
+ return (
29
+ <TitleLevelContext.Provider value={ clampTitleLevel( titleLevel ) ?? inheritedTitleLevel }>
30
+ <Stack className={ classNames( 'newspack-collapsible-group', className ) } direction="column" gap={ gap }>
31
+ { items.map( ( item, index ) => (
32
+ <Fragment key={ item.key }>
33
+ { hideTitle ? cloneElement( item, { title: undefined } ) : item }
34
+ { index < items.length - 1 && <Divider variant="tertiary" marginBottom={ 0 } marginTop={ 0 } /> }
35
+ </Fragment>
36
+ ) ) }
37
+ </Stack>
38
+ </TitleLevelContext.Provider>
39
+ );
40
+ };
41
+
42
+ export default Root;
@@ -0,0 +1,47 @@
1
+ @use "../mixins" as mixins;
2
+ @use "~@wordpress/base-styles/mixins" as wp-mixins;
3
+ @use "~@wordpress/base-styles/variables" as wp-vars;
4
+
5
+ .newspack-collapsible-group {
6
+ &__heading {
7
+ margin: 0;
8
+ }
9
+
10
+ &__trigger {
11
+ @include wp-mixins.heading-large();
12
+ align-items: center;
13
+ background: none;
14
+ border: 0;
15
+ color: inherit;
16
+ cursor: pointer;
17
+ display: flex;
18
+ gap: wp-vars.$grid-unit-10;
19
+ justify-content: space-between;
20
+ padding: 0;
21
+ text-align: left;
22
+ width: 100%;
23
+
24
+ &:focus-visible {
25
+ @include mixins.wpds-focus-ring;
26
+ outline-offset: wp-vars.$grid-unit-05;
27
+ }
28
+
29
+ svg {
30
+ flex-shrink: 0;
31
+ }
32
+
33
+ &[aria-expanded="true"] svg {
34
+ transform: rotate(180deg);
35
+ }
36
+
37
+ @media (prefers-reduced-motion: no-preference) {
38
+ svg {
39
+ transition: transform 0.1s ease-out;
40
+ }
41
+ }
42
+ }
43
+
44
+ &__panel-inner {
45
+ padding-top: wp-vars.$grid-unit-30;
46
+ }
47
+ }
@@ -0,0 +1,22 @@
1
+ import type { GapSize } from '@wordpress/theme';
2
+
3
+ export type HeadingLevel = 1 | 2 | 3 | 4 | 5 | 6;
4
+
5
+ export type CollapsibleGroupProps = {
6
+ className?: string;
7
+ /** `Stack` gap either side of each divider, so items sit twice this far apart. */
8
+ gap?: GapSize;
9
+ /** Renders a lone item open and untitled, since it has nothing to collapse against. */
10
+ hideSingleTitle?: boolean;
11
+ /** Heading level for every item title, so the group shares one place in the outline. Defaults to the level inherited from an enclosing group, or 2. */
12
+ titleLevel?: HeadingLevel;
13
+ children?: React.ReactNode;
14
+ };
15
+
16
+ export type CollapsibleGroupItemProps = {
17
+ className?: string;
18
+ defaultOpen?: boolean;
19
+ /** Without a title there is no trigger, so the content renders permanently open. */
20
+ title?: string;
21
+ children?: React.ReactNode;
22
+ };
@@ -10,8 +10,8 @@ the one part with no counterpart there.
10
10
 
11
11
  The parts hang off one exported object rather than the flat named exports the
12
12
  rest of this package uses. That is deliberate, to keep them recognisable against
13
- `@wordpress/ui`'s own `Drawer`, and not a pattern for the next compound
14
- component to copy.
13
+ `@wordpress/ui`'s own `Drawer`. It is also the shape compound components in this
14
+ package follow, `EmptyState` included.
15
15
 
16
16
  ```jsx
17
17
  import { Drawer } from 'newspack-components';
@@ -0,0 +1,165 @@
1
+ # EmptyState
2
+
3
+ The "nothing here yet" treatment for a list screen or an onboarding view: an
4
+ icon, a title, a short description, and usually one call to action.
5
+
6
+ The API is compound: an `EmptyState.Root` and one subcomponent per slot. The
7
+ parts hang off one exported object, as `Drawer`'s do.
8
+
9
+ Root brings the layout its consumers used to hand-write, including the
10
+ `data-start` and `data-end` attributes the `Grid` stylesheet matches on. The
11
+ parts are composed from `Stack`, so every gap belongs to a stack rather than to
12
+ a margin, and the component's own stylesheet covers only the icon disc and the
13
+ type of the title and description.
14
+
15
+ ## Importing
16
+
17
+ The package barrel and the component's own entry point both work:
18
+
19
+ ```jsx
20
+ // The barrel.
21
+ import { EmptyState } from 'newspack-components';
22
+
23
+ // The component on its own, from newspack-plugin.
24
+ import EmptyState from '../../packages/components/src/empty-state';
25
+ ```
26
+
27
+ The by-path form above is newspack-plugin's, which compiles the monorepo source
28
+ directly. newspack-newsletters resolves `newspack-components` to the built
29
+ `dist/esm/index.js`, so its only per-component route is
30
+ `newspack-components/dist/esm/empty-state`, which reaches past the package's
31
+ public surface: `main` is the only declared entry and there is no `exports` map
32
+ to make that path supported. Importing per component holds for one of the two
33
+ consumers until the package declares one.
34
+
35
+ Take the barrel where the bundle already pulls the package in wholesale, as the
36
+ newsletters admin shell does. Import by path where a bundle should stay narrow:
37
+ the barrel reaches `Page`, whose stylesheet carries a `:root` block of
38
+ design-system token overrides, and that block then rides into every bundle that
39
+ touches the barrel.
40
+
41
+ One by-path import does not settle it, and today none of them do. The package
42
+ declares no `sideEffects`, so a bundler cannot drop anything the barrel
43
+ re-exports, and every newspack-plugin screen using `EmptyState` still imports
44
+ `Button` or `Card` from the barrel on a neighbouring line. Until the package
45
+ declares `sideEffects`, importing by path is the direction of travel rather than
46
+ a saving already banked.
47
+
48
+ ## Usage
49
+
50
+ ```jsx
51
+ import { __ } from '@wordpress/i18n';
52
+ import { Button } from '@wordpress/components';
53
+ import { envelope } from '@wordpress/icons';
54
+ import { EmptyState } from 'newspack-components';
55
+
56
+ <EmptyState.Root className={ EMPTY_STATE_CLASS }>
57
+ <EmptyState.Header
58
+ icon={ envelope }
59
+ title={ __( 'Get started with newsletters', 'newspack-newsletters' ) }
60
+ description={ __( 'Compose, schedule, and send newsletters to your subscribers.', 'newspack-newsletters' ) }
61
+ />
62
+ <EmptyState.Actions>
63
+ <Button variant="primary" href={ addNewHref }>
64
+ { __( 'Add Newsletter', 'newspack-newsletters' ) }
65
+ </Button>
66
+ </EmptyState.Actions>
67
+ </EmptyState.Root>
68
+ ```
69
+
70
+ Every slot except `Root` is optional, and anything else you pass to `Root`
71
+ becomes a sibling of the header at the same 8-unit gap. A screen that offers
72
+ choices rather than one action can drop a stack of cards in instead of
73
+ `EmptyState.Actions`. Pass elements: `Root`'s stack keeps a lone string but drops
74
+ one sitting beside an element, so wrap loose text in a `<p>`. The `Grid` margin
75
+ reset reaches direct children only, so a `<p>` inside a slot keeps the browser's
76
+ default block margin and you zero it where you use it. The component resets
77
+ margins on the two elements it renders itself and stops there: a blanket reset on
78
+ slot content would silently flatten a consumer's own stack of cards or prose, and
79
+ the gaps this component owns all come from its stacks anyway.
80
+
81
+ ## Consumers own their wrappers
82
+
83
+ The component does not position itself on the page, so each screen decides
84
+ whether it needs a wrapper at all.
85
+
86
+ Pass a class to `Root` when the styling targets the empty state itself. The
87
+ newsletters screens do that, exporting the class from the shell rather than
88
+ repeating the literal: the shell keys `:has()` off it to hide its header and
89
+ hold the main region to 1006px, and both are rules about an empty state being
90
+ on screen.
91
+
92
+ Wrap `Root` in your own element when the wrapper is page layout that would
93
+ still be there without an empty state. `institutions/onboarding.tsx` does that:
94
+ `newspack-wizard__column` is the wizard's own column width, and the view
95
+ would want it whatever it rendered.
96
+
97
+ ## Strict-empty only
98
+
99
+ **Render this when the *unfiltered* collection is empty.** A search or filter
100
+ that matches nothing keeps the DataViews "no results" treatment, which tells
101
+ the reader their query was too narrow rather than that they have nothing.
102
+
103
+ The component cannot enforce that: it never sees the collection. In the
104
+ newsletters admin shell the rule lives in `isStrictlyEmpty`.
105
+
106
+ ## Actions take any button
107
+
108
+ `EmptyState.Actions` renders whatever you give it, so each consumer keeps its
109
+ own `Button`. newspack-newsletters passes the `@wordpress/components` one and
110
+ newspack-plugin passes this package's.
111
+
112
+ That is also why there is no CTA invariant. The component this replaced took a
113
+ `ctaHref` / `ctaOnClick` pair and required exactly one, throwing in development.
114
+ With a children slot there is no pair to check. A button that navigates takes
115
+ `href`; one that opens something in place takes `onClick`.
116
+
117
+ ## `EmptyState.Root`
118
+
119
+ | Prop | Type | Default | Description |
120
+ |------|------|---------|-------------|
121
+ | `children` | `React.ReactNode` | — | The slots, plus any custom body. |
122
+ | `className` | `string` | — | Merged onto the grid. |
123
+ | `size` | `'default'` \| `'small'` | `'default'` | Read by `EmptyState.Header`. `small` suits an empty state standing in for a panel inside a card. |
124
+
125
+ The grid always carries `newspack-empty-state`, and `className` lands there
126
+ rather than on a wrapper, because consumers key off both. Inside it, the stack
127
+ carries `newspack-empty-state__stack`, sits in columns two to four, and is
128
+ capped at `--wpds-dimension-surface-width-lg` so it cannot sprawl in a wider
129
+ container.
130
+
131
+ ## `EmptyState.Header`
132
+
133
+ | Prop | Type | Default | Description |
134
+ |------|------|---------|-------------|
135
+ | `className` | `string` | — | Merged onto `newspack-empty-state__header`. |
136
+ | `description` | `React.ReactNode` | — | One or two sentences on what would fill the screen. |
137
+ | `heading` | `1`–`6` | `3` when small, `2` otherwise | HTML heading level. |
138
+ | `icon` | `JSX.Element` | — | From `@wordpress/icons` or `newspack-icons`. |
139
+ | `title` | `string` | — | **Required.** |
140
+
141
+ `heading` follows `size` by default but stays yours to set. Heading level is a
142
+ document-outline concern rather than a visual one, so a headerless screen that
143
+ needs this to be its `h1` passes `heading={ 1 }`.
144
+
145
+ ## `EmptyState.Actions`
146
+
147
+ | Prop | Type | Default | Description |
148
+ |------|------|---------|-------------|
149
+ | `children` | `React.ReactNode` | — | Usually one primary button. |
150
+ | `className` | `string` | — | Merged onto the stack. |
151
+ | `orientation` | `'row'` \| `'column'` | `'row'` | `column` stacks the actions, for a button above a link or an explanatory note. |
152
+ | `gap` | `GapSize` | `'sm'` | Gap between actions, on the design-system scale (`xs` to `3xl`). |
153
+
154
+ A centred stack, carrying `newspack-empty-state__actions`. A row wraps rather
155
+ than overflowing, since the empty state only gets half the grid on a wide screen.
156
+ With one action, prefer a single primary button: an empty state asking for two
157
+ decisions at once is usually a sign the screen needs an onboarding view instead.
158
+
159
+ ## Outside the Root
160
+
161
+ `EmptyState.Header` and `EmptyState.Actions` both throw "EmptyState
162
+ subcomponents must be rendered inside EmptyState.Root." when rendered anywhere
163
+ else, in development only. In production `Header` falls back to the default
164
+ size and `Actions` renders as it would inside a Root, so a misplaced part never
165
+ blanks an admin screen.
@@ -0,0 +1,37 @@
1
+ /**
2
+ * WordPress dependencies.
3
+ */
4
+ import { Stack } from '@wordpress/ui';
5
+
6
+ /**
7
+ * External dependencies.
8
+ */
9
+ import classnames from 'classnames';
10
+
11
+ /**
12
+ * Internal dependencies.
13
+ */
14
+ import { useEmptyStateInvariant } from './context';
15
+ import type { EmptyStateActionsProps } from './types';
16
+
17
+ const Actions = ( { orientation = 'row', gap = 'sm', className, children }: EmptyStateActionsProps ) => {
18
+ useEmptyStateInvariant();
19
+
20
+ const isColumn = orientation === 'column';
21
+
22
+ // Rows wrap: the empty state only gets half the grid above 1054px.
23
+ return (
24
+ <Stack
25
+ direction={ isColumn ? 'column' : 'row' }
26
+ align="center"
27
+ justify="center"
28
+ gap={ gap }
29
+ wrap={ isColumn ? undefined : 'wrap' }
30
+ className={ classnames( 'newspack-empty-state__actions', className ) }
31
+ >
32
+ { children }
33
+ </Stack>
34
+ );
35
+ };
36
+
37
+ export default Actions;
@@ -0,0 +1,40 @@
1
+ /**
2
+ * WordPress dependencies.
3
+ */
4
+ import { createContext, useContext } from '@wordpress/element';
5
+
6
+ /**
7
+ * Internal dependencies.
8
+ */
9
+ import type { EmptyStateSize } from './types';
10
+
11
+ type EmptyStateContextValue = {
12
+ size: EmptyStateSize;
13
+ };
14
+
15
+ export const EmptyStateContext = createContext< EmptyStateContextValue | null >( null );
16
+
17
+ const FALLBACK_CONTEXT: EmptyStateContextValue = { size: 'default' };
18
+
19
+ export const useEmptyStateContext = (): EmptyStateContextValue => {
20
+ const context = useContext( EmptyStateContext );
21
+ if ( ! context && process.env.NODE_ENV !== 'production' ) {
22
+ throw new Error( 'EmptyState subcomponents must be rendered inside EmptyState.Root.' );
23
+ }
24
+ return context ?? FALLBACK_CONTEXT;
25
+ };
26
+
27
+ /**
28
+ * Assert placement inside a `Root` without depending on the value.
29
+ *
30
+ * For subcomponents that read nothing from context. Both hooks throw in
31
+ * development so the mistake surfaces while it is cheap, and neither throws in
32
+ * production, because a stray subcomponent should not blank an admin screen
33
+ * over a layout hint.
34
+ */
35
+ export const useEmptyStateInvariant = (): void => {
36
+ const context = useContext( EmptyStateContext );
37
+ if ( ! context && process.env.NODE_ENV !== 'production' ) {
38
+ throw new Error( 'EmptyState subcomponents must be rendered inside EmptyState.Root.' );
39
+ }
40
+ };
@@ -0,0 +1,48 @@
1
+ /**
2
+ * WordPress dependencies.
3
+ */
4
+ import { Icon } from '@wordpress/icons';
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 { useEmptyStateContext } from './context';
16
+ import type { EmptyStateHeaderProps } from './types';
17
+
18
+ const Header = ( { icon, title, description, heading, className }: EmptyStateHeaderProps ) => {
19
+ const { size } = useEmptyStateContext();
20
+ const isSmall = size === 'small';
21
+
22
+ // Heading level is a document-outline concern, so the size only sets a default.
23
+ const level = heading ?? ( isSmall ? 3 : 2 );
24
+ const HeadingTag = `h${ level }` as keyof JSX.IntrinsicElements;
25
+
26
+ // Two stacks so each gap belongs to exactly one of them: the inner one spaces the
27
+ // icon from the title, the outer one the title from the description.
28
+ return (
29
+ <Stack
30
+ direction="column"
31
+ align="center"
32
+ gap="sm"
33
+ className={ classnames( 'newspack-empty-state__header', isSmall && 'newspack-empty-state__header--small', className ) }
34
+ >
35
+ <Stack direction="column" align="center" gap={ isSmall ? 'md' : 'lg' }>
36
+ { icon && (
37
+ <div className="newspack-empty-state__icon">
38
+ <Icon icon={ icon } size={ isSmall ? 24 : 48 } />
39
+ </div>
40
+ ) }
41
+ <HeadingTag className="newspack-empty-state__title">{ title }</HeadingTag>
42
+ </Stack>
43
+ { description && <p className="newspack-empty-state__description">{ description }</p> }
44
+ </Stack>
45
+ );
46
+ };
47
+
48
+ export default Header;