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
@@ -2,10 +2,10 @@
2
2
  * Section Header
3
3
  */
4
4
 
5
+ @use "~@wordpress/base-styles/breakpoints" as wp-breakpoints;
5
6
  @use "~@wordpress/base-styles/colors" as wp-colors;
6
7
  @use "~@wordpress/base-styles/variables" as wp-vars;
7
8
  @use "~@wordpress/base-styles/mixins" as wp-mixins;
8
- @use "../../../colors/colors.module" as colors;
9
9
 
10
10
  .newspack-section-header {
11
11
  flex: 1;
@@ -22,6 +22,9 @@
22
22
  &:has(> .newspack-section-header--no-margin) {
23
23
  margin-top: 0;
24
24
  }
25
+ &:has(> .newspack-section-header--hidden) {
26
+ margin-block: 0;
27
+ }
25
28
  &.newspack-section-header--has-primary-action {
26
29
  display: flex;
27
30
  flex-direction: column;
@@ -32,7 +35,7 @@
32
35
  }
33
36
  }
34
37
  &.newspack-section-header--has-back-nav {
35
- @media (min-width: 782px) {
38
+ @include wp-mixins.break-wide {
36
39
  .newspack-section-header__title-container,
37
40
  p {
38
41
  max-width: calc(50% - 16px);
@@ -42,7 +45,10 @@
42
45
  }
43
46
 
44
47
  &__back-nav {
45
- @media (min-width: 1280px) {
48
+ @media (max-width: #{wp-breakpoints.$break-wide - 1px}) {
49
+ display: none;
50
+ }
51
+ @include wp-mixins.break-wide {
46
52
  left: 0;
47
53
  position: absolute;
48
54
  top: 50%;
@@ -101,6 +107,18 @@
101
107
  }
102
108
  }
103
109
 
110
+ &#{&}--hidden {
111
+ .newspack-section-header__hidden-text {
112
+ display: none;
113
+ }
114
+
115
+ .newspack-section-header__back-nav {
116
+ @include wp-mixins.break-wide {
117
+ transform: translate(calc(-100% - 8px), calc(-50% + 12px));
118
+ }
119
+ }
120
+ }
121
+
104
122
  &__icon {
105
123
  background-color: var(--wp-admin-theme-color-lighter-10);
106
124
  border-radius: 100%;
@@ -125,9 +143,7 @@
125
143
  display: flex;
126
144
  gap: 8px;
127
145
 
128
- .newspack-section-header__menu,
129
- .newspack-section-header__secondary-action,
130
- .newspack-badge {
146
+ .newspack-section-header__secondary-action {
131
147
  margin-top: 8px;
132
148
  }
133
149
  }
@@ -0,0 +1,266 @@
1
+ /**
2
+ * WordPress dependencies.
3
+ */
4
+ import { Fragment, useEffect, useState } from '@wordpress/element';
5
+ import { __, _n, sprintf } from '@wordpress/i18n';
6
+
7
+ /**
8
+ * Internal dependencies.
9
+ */
10
+ import { SelectControl, TextControl } from '../';
11
+
12
+ /**
13
+ * A bound is stored as { type: 'absolute', date } or { type: 'relative', days },
14
+ * where days is negative for the past and positive for the future. The UI splits
15
+ * the relative case in two so a publisher never has to type a minus sign.
16
+ */
17
+ const BOUND_TYPES = [
18
+ { label: __( 'Any', 'newspack-plugin' ), value: '' },
19
+ { label: __( 'Date', 'newspack-plugin' ), value: 'absolute' },
20
+ { label: __( 'Days ago', 'newspack-plugin' ), value: 'past' },
21
+ { label: __( 'Days from now', 'newspack-plugin' ), value: 'future' },
22
+ ];
23
+
24
+ // Upper magnitude for a relative bound: ~273 years, far inside Date's range,
25
+ // and it keeps the resolved year four digits so the matcher's lexicographic
26
+ // compare stays valid. Import and REST bypass this input, so the matcher still
27
+ // fails closed on larger offsets — this just turns a wild typo into
28
+ // browser-level validation instead of a segment that matches nobody.
29
+ const MAX_RELATIVE_DAYS = 100000;
30
+
31
+ // Today as YYYY-MM-DD in local time. toISOString() converts to UTC and would give
32
+ // the wrong day for anyone west of Greenwich.
33
+ const today = () => {
34
+ const date = new Date();
35
+ const month = String( date.getMonth() + 1 ).padStart( 2, '0' );
36
+ const day = String( date.getDate() ).padStart( 2, '0' );
37
+ return `${ date.getFullYear() }-${ month }-${ day }`;
38
+ };
39
+
40
+ // Zero days is the same day either way, and normalizes to "Days ago".
41
+ const boundTypeOf = bound => {
42
+ if ( ! bound || ! bound.type ) {
43
+ return '';
44
+ }
45
+ if ( 'absolute' === bound.type ) {
46
+ return 'absolute';
47
+ }
48
+ return bound.days > 0 ? 'future' : 'past';
49
+ };
50
+
51
+ const boundValueOf = bound => {
52
+ if ( ! bound ) {
53
+ return '';
54
+ }
55
+ return 'absolute' === bound.type ? bound.date || '' : String( Math.abs( bound.days || 0 ) );
56
+ };
57
+
58
+ // `seed` is set only when the publisher picks a bound type, so a newly chosen
59
+ // bound starts on a usable value instead of being half-filled. It must stay off
60
+ // for edits to the value itself: a date input reports '' both when cleared and
61
+ // transiently while its value is being retyped, and substituting today() there
62
+ // would silently move a saved window out from under the publisher.
63
+ const makeBound = ( type, rawValue, seed = false ) => {
64
+ if ( 'absolute' === type ) {
65
+ const date = rawValue || ( seed ? today() : '' );
66
+ // An absolute bound with no date is not a bound: emitted and saved, it
67
+ // would reach the matcher as a bound it can only reject, silently
68
+ // shrinking the segment to nobody. The row itself stays open — the
69
+ // control falls back to the chosen bound type while the bound is absent.
70
+ return date ? { type: 'absolute', date } : undefined;
71
+ }
72
+ // A cleared magnitude mirrors a cleared date: no bound at all, not a
73
+ // zero-day bound — { days: 0 } would quietly move the window's edge to
74
+ // today and snap the emptied input back to '0' mid-retype. An explicit
75
+ // '0' is falsy-but-present, so the check is on emptiness, not truthiness.
76
+ const raw = '' === rawValue && seed ? '0' : rawValue;
77
+ if ( '' === raw ) {
78
+ return undefined;
79
+ }
80
+ // Number() rather than parseInt(): a number input accepts scientific
81
+ // notation, and parseInt( '1e2' ) reads it as 1 instead of 100.
82
+ const days = Math.abs( Math.trunc( Number( raw ) ) || 0 );
83
+ return { type: 'relative', days: 'future' === type ? days : -days };
84
+ };
85
+
86
+ const DateRangeBound = ( { label, testId, bound, onChange } ) => {
87
+ // The bound type is normally derived from the stored value, but a zero-magnitude
88
+ // relative bound (`days: 0`) is the same day whether the publisher chose "ago" or
89
+ // "from now" — the sign can't tell them apart. `chosenType` records the direction
90
+ // to fall back on while the bound stays ambiguous, and starts out `null` — no
91
+ // direction known yet — since the bound this control receives can arrive
92
+ // asynchronously (e.g. an existing segment fetched after first mount).
93
+ const [ chosenType, setChosenType ] = useState( null );
94
+ const derivedType = boundTypeOf( bound );
95
+ const isAmbiguous = 'relative' === bound?.type && 0 === bound.days;
96
+
97
+ // Keep chosenType in step with any unambiguous bound — freshly chosen by the
98
+ // publisher in this control, or loaded from storage — so the direction survives
99
+ // if the value is later cleared down to an ambiguous zero. A bound of '' (no
100
+ // bound at all, e.g. before an async load resolves) doesn't establish a
101
+ // direction; only a real, unambiguous one does.
102
+ useEffect( () => {
103
+ if ( '' !== derivedType && ! isAmbiguous ) {
104
+ setChosenType( derivedType );
105
+ }
106
+ }, [ derivedType, isAmbiguous ] );
107
+
108
+ // Also fall back while there is no bound at all: a cleared date emits no
109
+ // bound (see makeBound), and deriving from the absent value would collapse
110
+ // the row to "Any" mid-edit instead of leaving the empty input for retyping.
111
+ const type = null !== chosenType && ( isAmbiguous || ! bound ) ? chosenType : derivedType;
112
+ return (
113
+ <div className="newspack-settings__date-range-bound">
114
+ <SelectControl
115
+ label={ label }
116
+ // Without this the select renders at 32px while the sibling input is
117
+ // 40px, so the two boxes in a row don't match.
118
+ __next40pxDefaultSize
119
+ value={ type }
120
+ options={ BOUND_TYPES }
121
+ onChange={ nextType => {
122
+ setChosenType( nextType );
123
+ onChange( nextType ? makeBound( nextType, '', true ) : undefined );
124
+ } }
125
+ />
126
+ { '' !== type && (
127
+ <TextControl
128
+ data-testid={ testId }
129
+ // The visible label sits on the sibling select, so without one here
130
+ // the input has no accessible name at all. Composed with the row
131
+ // label so the two bound rows' inputs stay distinguishable — two
132
+ // bare "Days" spin buttons announce identically.
133
+ label={
134
+ 'absolute' === type
135
+ ? // translators: %s: the bound row label (From/To).
136
+ sprintf( __( '%s date', 'newspack-plugin' ), label )
137
+ : // translators: %s: the bound row label (From/To).
138
+ sprintf( __( '%s days', 'newspack-plugin' ), label )
139
+ }
140
+ hideLabelFromVision
141
+ type={ 'absolute' === type ? 'date' : 'number' }
142
+ min={ 'absolute' === type ? undefined : 0 }
143
+ max={ 'absolute' === type ? undefined : MAX_RELATIVE_DAYS }
144
+ value={ boundValueOf( bound ) }
145
+ onChange={ nextValue => onChange( makeBound( type, nextValue ) ) }
146
+ />
147
+ ) }
148
+ </div>
149
+ );
150
+ };
151
+
152
+ /**
153
+ * Rolling windows offered as one-click presets. Each is the trailing window
154
+ * ending today, i.e. `start: -N days`, `end: today` — the same shape the custom
155
+ * rows produce, so a preset is a shortcut rather than a separate stored format.
156
+ */
157
+ const PRESET_DAYS = [ 7, 30, 365 ];
158
+
159
+ const PRESETS = [
160
+ { label: __( 'Any', 'newspack-plugin' ), value: '' },
161
+ /* translators: %d: number of days in a trailing date window. */
162
+ ...PRESET_DAYS.map( days => ( { label: sprintf( _n( 'Last %d day', 'Last %d days', days, 'newspack-plugin' ), days ), value: String( days ) } ) ),
163
+ { label: __( 'Custom', 'newspack-plugin' ), value: 'custom' },
164
+ ];
165
+
166
+ /**
167
+ * Which preset a stored range corresponds to.
168
+ *
169
+ * @param {*} start The start bound.
170
+ * @param {*} end The end bound.
171
+ * @return {string} A PRESETS value: '' when unbounded, the day count when the
172
+ * range is exactly one of the presets, otherwise 'custom'.
173
+ */
174
+ const presetOf = ( start, end ) => {
175
+ if ( ! start && ! end ) {
176
+ return '';
177
+ }
178
+ if (
179
+ start &&
180
+ end &&
181
+ 'relative' === start.type &&
182
+ 'relative' === end.type &&
183
+ 0 === end.days &&
184
+ start.days < 0 &&
185
+ PRESET_DAYS.includes( Math.abs( start.days ) )
186
+ ) {
187
+ return String( Math.abs( start.days ) );
188
+ }
189
+ return 'custom';
190
+ };
191
+
192
+ const DateRangeSetting = ( { start, end, onChange, label, ...props } ) => {
193
+ const derivedPreset = presetOf( start, end );
194
+ // A range that matches a preset is indistinguishable from the same range built
195
+ // by hand, and an empty one is indistinguishable from "Custom, nothing filled
196
+ // in yet" — so a deliberate Custom choice has to be remembered rather than
197
+ // re-derived. Starts null: nothing chosen yet, so the stored value decides.
198
+ const [ chosenPreset, setChosenPreset ] = useState( null );
199
+
200
+ // Custom explains any range, so it is never overridden by what the value looks
201
+ // like. Every other selection stays in step with a range that arrived from
202
+ // storage or changed elsewhere.
203
+ useEffect( () => {
204
+ setChosenPreset( chosen => ( 'custom' === chosen ? chosen : derivedPreset ) );
205
+ }, [ derivedPreset ] );
206
+
207
+ const preset = null === chosenPreset ? derivedPreset : chosenPreset;
208
+
209
+ // Both keys are always emitted: the consumer merges the value into the stored
210
+ // criterion, so omitting one would leave a stale bound behind. An empty range
211
+ // emits nothing at all, which drops the criterion — an empty `{}` would reach
212
+ // the matcher as "no bounds to check" and match every reader with a date.
213
+ const changeRange = ( nextStart, nextEnd ) => onChange( nextStart || nextEnd ? { start: nextStart, end: nextEnd } : undefined );
214
+
215
+ const changePreset = next => {
216
+ setChosenPreset( next );
217
+ if ( 'custom' === next ) {
218
+ // Keep the current range so the rows open on what the preset meant.
219
+ return;
220
+ }
221
+ if ( '' === next ) {
222
+ onChange( undefined );
223
+ return;
224
+ }
225
+ changeRange( { type: 'relative', days: -Number( next ) }, { type: 'relative', days: 0 } );
226
+ };
227
+
228
+ return (
229
+ <div { ...props }>
230
+ <div className="newspack-settings__date-range-preset">
231
+ <SelectControl
232
+ // The criterion's own name is the visible heading of the section
233
+ // this renders into, so a visible label here would just repeat it —
234
+ // but the heading is a bare span with no label association, so the
235
+ // consumer-passed name is what keeps several date criteria's preset
236
+ // selectors distinguishable to a screen reader.
237
+ label={ label || __( 'Date range', 'newspack-plugin' ) }
238
+ hideLabelFromVision
239
+ __next40pxDefaultSize
240
+ data-testid="date-range-preset"
241
+ value={ preset }
242
+ options={ PRESETS }
243
+ onChange={ changePreset }
244
+ />
245
+ </div>
246
+ { 'custom' === preset && (
247
+ <Fragment>
248
+ <DateRangeBound
249
+ label={ __( 'From', 'newspack-plugin' ) }
250
+ testId="date-range-start-value"
251
+ bound={ start }
252
+ onChange={ nextStart => changeRange( nextStart, end ) }
253
+ />
254
+ <DateRangeBound
255
+ label={ __( 'To', 'newspack-plugin' ) }
256
+ testId="date-range-end-value"
257
+ bound={ end }
258
+ onChange={ nextEnd => changeRange( start, nextEnd ) }
259
+ />
260
+ </Fragment>
261
+ ) }
262
+ </div>
263
+ );
264
+ };
265
+
266
+ export default DateRangeSetting;
@@ -1,14 +1,34 @@
1
+ /**
2
+ * WordPress dependencies.
3
+ */
4
+ import { __, sprintf } from '@wordpress/i18n';
5
+ import { Stack } from '@wordpress/ui';
6
+
1
7
  /**
2
8
  * Internal dependencies.
3
9
  */
4
- import { Grid, InfoButton } from '../';
10
+ import Grid from '../grid';
11
+ import InfoButton from '../info-button';
5
12
 
6
13
  const SettingSection = ( { title, description, children } ) => (
7
14
  <Grid columns={ 1 } gutter={ 8 } className="newspack-settings__section">
8
- <div className="newspack-settings__section__title">
15
+ <Stack direction="row" align="flex-start" gap="xs" className="newspack-settings__section__title">
9
16
  <span>{ title }</span>
10
- { description && <InfoButton text={ description } /> }
11
- </div>
17
+ { description && (
18
+ <InfoButton
19
+ description={ description }
20
+ triggerLabel={
21
+ typeof title === 'string'
22
+ ? sprintf(
23
+ // translators: %s is the name of the setting being explained.
24
+ __( 'More information about %s', 'newspack-plugin' ),
25
+ title
26
+ )
27
+ : __( 'More information', 'newspack-plugin' )
28
+ }
29
+ />
30
+ ) }
31
+ </Stack>
12
32
  <div className="newspack-settings__section__content">{ children }</div>
13
33
  </Grid>
14
34
  );
@@ -0,0 +1,45 @@
1
+ /**
2
+ * External dependencies.
3
+ */
4
+ import { fireEvent, render, screen } from '@testing-library/react';
5
+
6
+ /**
7
+ * Internal dependencies.
8
+ */
9
+ import SettingsSection from './SettingsSection';
10
+
11
+ const DESCRIPTION = 'Number of articles read in the last 30 day period.';
12
+
13
+ describe( 'SettingsSection', () => {
14
+ it( 'reveals the description from the info trigger', () => {
15
+ render(
16
+ <SettingsSection title="Articles read" description={ DESCRIPTION }>
17
+ <input />
18
+ </SettingsSection>
19
+ );
20
+ expect( screen.queryByText( DESCRIPTION ) ).not.toBeInTheDocument();
21
+
22
+ fireEvent.click( screen.getByRole( 'button', { name: 'More information about Articles read' } ) );
23
+
24
+ expect( screen.getByText( DESCRIPTION ) ).toBeInTheDocument();
25
+ } );
26
+
27
+ it( 'falls back to a plain trigger name when the title is not a string', () => {
28
+ render(
29
+ <SettingsSection title={ <em>Articles read</em> } description={ DESCRIPTION }>
30
+ <input />
31
+ </SettingsSection>
32
+ );
33
+ expect( screen.getByRole( 'button', { name: 'More information' } ) ).toBeInTheDocument();
34
+ } );
35
+
36
+ it( 'renders no info trigger without a description', () => {
37
+ render(
38
+ <SettingsSection title="Articles read">
39
+ <input />
40
+ </SettingsSection>
41
+ );
42
+ expect( screen.getByText( 'Articles read' ) ).toBeInTheDocument();
43
+ expect( screen.queryByRole( 'button' ) ).not.toBeInTheDocument();
44
+ } );
45
+ } );
@@ -1,6 +1,7 @@
1
1
  import SettingsCard from './SettingsCard';
2
2
  import SettingsSection from './SettingsSection';
3
3
  import MinMaxSetting from './MinMaxSetting';
4
+ import DateRangeSetting from './DateRangeSetting';
4
5
 
5
6
  import './style.scss';
6
7
 
@@ -8,4 +9,5 @@ export default {
8
9
  SettingsCard,
9
10
  SettingsSection,
10
11
  MinMaxSetting,
12
+ DateRangeSetting,
11
13
  };
@@ -1,3 +1,5 @@
1
+ @use "~@wordpress/base-styles/variables" as wp-vars;
2
+
1
3
  .newspack-settings {
2
4
  &__card {
3
5
  &.newspack-card.newspack-action-card {
@@ -28,17 +30,18 @@
28
30
 
29
31
  .newspack-settings__section {
30
32
  &__title {
31
- align-items: center;
32
- display: flex;
33
- margin: 0;
34
-
35
33
  span {
36
- line-height: 24px;
34
+ line-height: wp-vars.$font-line-height-small;
35
+ }
36
+
37
+ .newspack-info-button {
38
+ margin-block: calc((#{wp-vars.$font-line-height-small} - #{wp-vars.$button-size-small}) / 2);
37
39
  }
38
40
  }
39
41
  &__content {
40
42
  > div,
41
- .newspack-settings__min-max {
43
+ .newspack-settings__min-max,
44
+ .newspack-settings__date-range-bound {
42
45
  margin: 0;
43
46
  display: flex;
44
47
  align-items: center;
@@ -49,7 +52,19 @@
49
52
  }
50
53
  }
51
54
  .newspack-autocomplete-tokenfield,
52
- .newspack-settings__min-max {
55
+ .newspack-settings__min-max,
56
+ .newspack-settings__date-range-bound {
57
+ width: 100%;
58
+ }
59
+ .newspack-settings__date-range-bound {
60
+ // Overrides the `center` above, which has to be beaten at this
61
+ // nesting level. The select carries the visible From/To label and
62
+ // the value input's is hidden, so the two controls differ in
63
+ // overall height; centering floats the input up against the label,
64
+ // while bottom-aligning lines the two boxes up with each other.
65
+ align-items: flex-end;
66
+ }
67
+ .newspack-settings__date-range-preset {
53
68
  width: 100%;
54
69
  }
55
70
  .newspack-autocomplete-tokenfield {
@@ -90,6 +105,24 @@
90
105
  margin-top: 16px !important;
91
106
  }
92
107
  }
108
+ .newspack-settings__date-range-preset + .newspack-settings__date-range-bound {
109
+ margin-top: 16px !important;
110
+ }
111
+ .newspack-settings__date-range-bound {
112
+ gap: 8px;
113
+
114
+ .newspack-select-control {
115
+ flex: 0 0 auto;
116
+ }
117
+
118
+ .newspack-text-control {
119
+ flex: 1 1 auto;
120
+ }
121
+
122
+ + .newspack-settings__date-range-bound {
123
+ margin-top: 16px !important;
124
+ }
125
+ }
93
126
  }
94
127
 
95
128
  &__no-border {