newspack-components 4.6.2 → 4.7.0-alpha.1

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 (148) hide show
  1. package/CHANGELOG.md +23 -0
  2. package/colors/colors.module.scss +13 -7
  3. package/dist/cjs/autocomplete-tokenfield/style.scss +4 -2
  4. package/dist/cjs/breadcrumbs/format-count.js +33 -0
  5. package/dist/cjs/breadcrumbs/index.js +78 -20
  6. package/dist/cjs/breadcrumbs/index.test.js +110 -0
  7. package/dist/cjs/breadcrumbs/style.scss +6 -0
  8. package/dist/cjs/button/index.js +41 -17
  9. package/dist/cjs/button/index.test.js +273 -0
  10. package/dist/cjs/card/core-card.js +9 -4
  11. package/dist/cjs/card/core-card.test.js +29 -0
  12. package/dist/cjs/card/index.js +4 -2
  13. package/dist/cjs/card/style-core.scss +59 -0
  14. package/dist/cjs/card-feature/index.js +7 -1
  15. package/dist/cjs/card-feature/index.test.js +92 -0
  16. package/dist/cjs/color-picker/index.js +1 -1
  17. package/dist/cjs/confirm-dialog/index.js +26 -9
  18. package/dist/cjs/confirm-dialog/index.test.js +176 -0
  19. package/dist/cjs/dataviews/index.js +11 -2
  20. package/dist/cjs/divider/index.js +13 -0
  21. package/dist/cjs/drawer/action.js +74 -0
  22. package/dist/cjs/drawer/body.js +79 -0
  23. package/dist/cjs/drawer/close-icon.js +45 -0
  24. package/dist/cjs/drawer/content.js +72 -0
  25. package/dist/cjs/drawer/context.js +23 -0
  26. package/dist/cjs/drawer/context.test.js +43 -0
  27. package/dist/cjs/drawer/divider.js +24 -0
  28. package/dist/cjs/drawer/footer.js +26 -0
  29. package/dist/cjs/drawer/header.js +26 -0
  30. package/dist/cjs/drawer/index.js +42 -0
  31. package/dist/cjs/drawer/index.test.js +1287 -0
  32. package/dist/cjs/drawer/root.js +350 -0
  33. package/dist/cjs/drawer/slots.test.js +422 -0
  34. package/dist/cjs/drawer/style.scss +205 -0
  35. package/dist/cjs/drawer/title.js +54 -0
  36. package/dist/cjs/drawer/types.js +5 -0
  37. package/dist/cjs/drawer/use-exit-animation.js +59 -0
  38. package/dist/cjs/hooks/use-confirm-dialog.js +15 -3
  39. package/dist/cjs/hooks/use-unsaved-changes-dialog.js +19 -4
  40. package/dist/cjs/index.js +28 -1
  41. package/dist/cjs/page/index.js +1 -1
  42. package/dist/cjs/page-control/index.js +82 -0
  43. package/dist/cjs/plugin-settings/SettingsSection.js +7 -1
  44. package/dist/cjs/section-header/index.js +5 -2
  45. package/dist/cjs/section-header/style.scss +25 -12
  46. package/dist/cjs/table-card/index.js +67 -0
  47. package/dist/cjs/table-card/index.test.js +153 -0
  48. package/dist/cjs/with-wizard-screen/style.scss +8 -0
  49. package/dist/cjs/wizard/index.js +102 -38
  50. package/dist/esm/autocomplete-tokenfield/style.scss +4 -2
  51. package/dist/esm/breadcrumbs/format-count.js +27 -0
  52. package/dist/esm/breadcrumbs/index.js +81 -20
  53. package/dist/esm/breadcrumbs/index.test.js +110 -0
  54. package/dist/esm/breadcrumbs/style.scss +6 -0
  55. package/dist/esm/button/index.js +42 -18
  56. package/dist/esm/button/index.test.js +273 -0
  57. package/dist/esm/card/core-card.js +9 -4
  58. package/dist/esm/card/core-card.test.js +29 -0
  59. package/dist/esm/card/index.js +4 -2
  60. package/dist/esm/card/style-core.scss +59 -0
  61. package/dist/esm/card-feature/index.js +8 -2
  62. package/dist/esm/card-feature/index.test.js +96 -0
  63. package/dist/esm/color-picker/index.js +1 -1
  64. package/dist/esm/confirm-dialog/index.js +26 -9
  65. package/dist/esm/confirm-dialog/index.test.js +176 -0
  66. package/dist/esm/dataviews/index.js +11 -2
  67. package/dist/esm/divider/index.js +14 -0
  68. package/dist/esm/drawer/action.js +68 -0
  69. package/dist/esm/drawer/body.js +70 -0
  70. package/dist/esm/drawer/close-icon.js +37 -0
  71. package/dist/esm/drawer/content.js +63 -0
  72. package/dist/esm/drawer/context.js +17 -0
  73. package/dist/esm/drawer/context.test.js +40 -0
  74. package/dist/esm/drawer/divider.js +16 -0
  75. package/dist/esm/drawer/footer.js +18 -0
  76. package/dist/esm/drawer/header.js +18 -0
  77. package/dist/esm/drawer/index.js +35 -0
  78. package/dist/esm/drawer/index.test.js +1285 -0
  79. package/dist/esm/drawer/root.js +342 -0
  80. package/dist/esm/drawer/slots.test.js +422 -0
  81. package/dist/esm/drawer/style.scss +205 -0
  82. package/dist/esm/drawer/title.js +46 -0
  83. package/dist/esm/drawer/types.js +1 -0
  84. package/dist/esm/drawer/use-exit-animation.js +52 -0
  85. package/dist/esm/hooks/use-confirm-dialog.js +15 -3
  86. package/dist/esm/hooks/use-unsaved-changes-dialog.js +20 -5
  87. package/dist/esm/index.js +4 -0
  88. package/dist/esm/page/index.js +1 -1
  89. package/dist/esm/page-control/index.js +82 -0
  90. package/dist/esm/plugin-settings/SettingsSection.js +8 -2
  91. package/dist/esm/section-header/index.js +5 -2
  92. package/dist/esm/section-header/style.scss +25 -12
  93. package/dist/esm/table-card/index.js +60 -0
  94. package/dist/esm/table-card/index.test.js +150 -0
  95. package/dist/esm/with-wizard-screen/style.scss +8 -0
  96. package/dist/esm/wizard/index.js +104 -38
  97. package/package.json +2 -2
  98. package/src/autocomplete-tokenfield/style.scss +4 -2
  99. package/src/breadcrumbs/format-count.js +27 -0
  100. package/src/breadcrumbs/index.js +68 -8
  101. package/src/breadcrumbs/index.test.js +57 -0
  102. package/src/breadcrumbs/style.scss +6 -0
  103. package/src/button/index.test.js +143 -0
  104. package/src/button/index.tsx +32 -7
  105. package/src/card/core-card.js +15 -2
  106. package/src/card/core-card.test.js +19 -0
  107. package/src/card/index.js +3 -0
  108. package/src/card/style-core.scss +59 -0
  109. package/src/card-feature/README.md +4 -0
  110. package/src/card-feature/index.test.js +65 -0
  111. package/src/card-feature/index.tsx +13 -2
  112. package/src/color-picker/index.js +1 -1
  113. package/src/confirm-dialog/README.md +3 -2
  114. package/src/confirm-dialog/index.test.js +146 -0
  115. package/src/confirm-dialog/index.tsx +23 -9
  116. package/src/dataviews/index.tsx +11 -2
  117. package/src/divider/index.js +13 -0
  118. package/src/drawer/README.md +454 -0
  119. package/src/drawer/action.tsx +67 -0
  120. package/src/drawer/body.tsx +60 -0
  121. package/src/drawer/close-icon.tsx +37 -0
  122. package/src/drawer/content.tsx +54 -0
  123. package/src/drawer/context.test.js +31 -0
  124. package/src/drawer/context.ts +25 -0
  125. package/src/drawer/divider.tsx +13 -0
  126. package/src/drawer/footer.tsx +15 -0
  127. package/src/drawer/header.tsx +15 -0
  128. package/src/drawer/index.test.js +1012 -0
  129. package/src/drawer/index.tsx +33 -0
  130. package/src/drawer/root.tsx +328 -0
  131. package/src/drawer/slots.test.js +314 -0
  132. package/src/drawer/style.scss +205 -0
  133. package/src/drawer/title.tsx +40 -0
  134. package/src/drawer/types.ts +79 -0
  135. package/src/drawer/use-exit-animation.ts +46 -0
  136. package/src/hooks/use-confirm-dialog.tsx +14 -3
  137. package/src/hooks/use-unsaved-changes-dialog.tsx +17 -5
  138. package/src/index.js +4 -0
  139. package/src/page/index.js +1 -1
  140. package/src/page-control/index.js +63 -0
  141. package/src/plugin-settings/SettingsSection.js +8 -2
  142. package/src/section-header/index.js +5 -2
  143. package/src/section-header/style.scss +25 -12
  144. package/src/table-card/README.md +100 -0
  145. package/src/table-card/index.test.js +112 -0
  146. package/src/table-card/index.tsx +56 -0
  147. package/src/with-wizard-screen/style.scss +8 -0
  148. package/src/wizard/index.js +95 -34
@@ -0,0 +1,205 @@
1
+ @use "~@wordpress/base-styles/breakpoints" as wp-breakpoints;
2
+ @use "~@wordpress/base-styles/colors" as wp-colors;
3
+ @use "~@wordpress/base-styles/variables" as wp-vars;
4
+
5
+ $drawer-size-small: 280px;
6
+ $drawer-size-medium: 350px;
7
+ $drawer-size-large: 480px;
8
+ $drawer-size-x-large: 640px;
9
+
10
+ .newspack-drawer,
11
+ .newspack-drawer *,
12
+ .newspack-drawer *::before,
13
+ .newspack-drawer *::after {
14
+ box-sizing: border-box;
15
+ }
16
+
17
+ .newspack-drawer {
18
+ display: flex;
19
+ flex-direction: column;
20
+ }
21
+
22
+ .components-modal__screen-overlay.newspack-drawer__overlay {
23
+ align-items: stretch;
24
+ justify-content: flex-end;
25
+ padding: 0;
26
+ }
27
+
28
+ .components-modal__frame.newspack-drawer {
29
+ animation: 0.2s ease-out newspack-drawer-slide-in;
30
+ border-radius: wp-vars.$radius-large 0 0 wp-vars.$radius-large;
31
+ height: 100%;
32
+ margin: 0;
33
+ max-height: none;
34
+ min-width: 0;
35
+ overflow: hidden;
36
+ width: 100%;
37
+
38
+ &.is-exiting {
39
+ animation: 0.2s ease-in forwards newspack-drawer-slide-out;
40
+ }
41
+
42
+ .components-modal__content {
43
+ display: flex;
44
+ flex-direction: column;
45
+ padding: 0;
46
+ }
47
+
48
+ .components-modal__children-container {
49
+ display: flex;
50
+ flex: 1 1 auto;
51
+ flex-direction: column;
52
+ min-height: 0;
53
+ }
54
+
55
+ &.newspack-drawer--size-small { max-width: $drawer-size-small; }
56
+ &.newspack-drawer--size-medium { max-width: $drawer-size-medium; }
57
+ &.newspack-drawer--size-large { max-width: $drawer-size-large; }
58
+ &.newspack-drawer--size-x-large { max-width: $drawer-size-x-large; }
59
+ &.newspack-drawer--size-full {
60
+ border-radius: 0;
61
+ max-width: 100vw;
62
+ }
63
+
64
+ @media (max-width: $drawer-size-x-large) {
65
+ &.newspack-drawer--size-x-large {
66
+ border-radius: 0;
67
+ }
68
+ }
69
+
70
+ @media (max-width: wp-breakpoints.$break-small) {
71
+ &.newspack-drawer--size-small,
72
+ &.newspack-drawer--size-medium,
73
+ &.newspack-drawer--size-large,
74
+ &.newspack-drawer--size-x-large,
75
+ &.newspack-drawer--size-full {
76
+ border-radius: 0;
77
+ max-width: 100vw;
78
+ }
79
+ }
80
+ }
81
+
82
+ .newspack-drawer__header {
83
+ align-items: center;
84
+ display: flex;
85
+ flex: 0 0 auto;
86
+ gap: wp-vars.$grid-unit-10;
87
+ padding: wp-vars.$grid-unit-30 wp-vars.$grid-unit-30 wp-vars.$grid-unit-20;
88
+ }
89
+
90
+ .newspack-drawer__icon {
91
+ fill: currentcolor;
92
+ flex: none;
93
+ }
94
+
95
+ .newspack-drawer__title {
96
+ flex: 1;
97
+ font-size: inherit;
98
+ font-weight: wp-vars.$font-weight-medium;
99
+ margin: 0;
100
+ min-width: 0;
101
+ }
102
+
103
+ .newspack-drawer__dismiss {
104
+ flex: none;
105
+ margin-left: auto;
106
+ }
107
+
108
+ .newspack-drawer__body {
109
+ flex: 1 1 auto;
110
+ overflow-y: auto;
111
+
112
+ > * {
113
+ flex: none;
114
+ }
115
+ }
116
+
117
+ .newspack-drawer__content {
118
+ padding: var(--newspack-drawer-content-padding, #{wp-vars.$grid-unit-30});
119
+
120
+ > * {
121
+ margin-bottom: 0;
122
+ margin-top: 0;
123
+ }
124
+ }
125
+
126
+ .newspack-drawer__divider {
127
+ border: 0;
128
+ border-top: wp-vars.$border-width solid wp-colors.$gray-300;
129
+ margin: 0;
130
+ }
131
+
132
+ .newspack-drawer__header + .newspack-drawer__body .newspack-drawer__content:first-child {
133
+ padding-top: 0;
134
+ }
135
+
136
+ .newspack-drawer__body:has(+ .newspack-drawer__footer) .newspack-drawer__content:last-child {
137
+ padding-bottom: 0;
138
+ }
139
+
140
+ .newspack-drawer__footer {
141
+ background: wp-colors.$white;
142
+ display: flex;
143
+ flex: 0 0 auto;
144
+ gap: wp-vars.$grid-unit-10;
145
+ padding: wp-vars.$grid-unit-20 wp-vars.$grid-unit-30 wp-vars.$grid-unit-30;
146
+
147
+ > .components-button {
148
+ flex: 1;
149
+ justify-content: center;
150
+ }
151
+
152
+ &:has(> :nth-child(3)) {
153
+ flex-direction: column;
154
+
155
+ > .components-button {
156
+ flex: 0 0 auto;
157
+ }
158
+ }
159
+ }
160
+
161
+ @keyframes newspack-drawer-slide-in {
162
+ from {
163
+ transform: translateX(100%);
164
+ }
165
+
166
+ to {
167
+ transform: translateX(0);
168
+ }
169
+ }
170
+
171
+ @keyframes newspack-drawer-slide-out {
172
+ from {
173
+ transform: translateX(0);
174
+ }
175
+
176
+ to {
177
+ transform: translateX(100%);
178
+ }
179
+ }
180
+
181
+ @keyframes newspack-drawer-overlay-fade-out {
182
+ from {
183
+ opacity: 1;
184
+ }
185
+
186
+ to {
187
+ opacity: 0;
188
+ }
189
+ }
190
+
191
+ .newspack-drawer__overlay.is-exiting {
192
+ animation: 0.2s ease-in forwards newspack-drawer-overlay-fade-out;
193
+ }
194
+
195
+ .components-modal__frame.newspack-drawer .components-popover__content {
196
+ opacity: 1;
197
+ }
198
+
199
+ @media (prefers-reduced-motion: reduce) {
200
+ .components-modal__frame.newspack-drawer,
201
+ .components-modal__frame.newspack-drawer.is-exiting,
202
+ .newspack-drawer__overlay.is-exiting {
203
+ animation: none;
204
+ }
205
+ }
@@ -0,0 +1,40 @@
1
+ /**
2
+ * WordPress dependencies.
3
+ */
4
+ import { useInstanceId } from '@wordpress/compose';
5
+ import { useLayoutEffect } from '@wordpress/element';
6
+
7
+ /**
8
+ * External dependencies.
9
+ */
10
+ import classnames from 'classnames';
11
+
12
+ /**
13
+ * Internal dependencies.
14
+ */
15
+ import { useDrawerContext } from './context';
16
+ import type { DrawerTitleProps } from './types';
17
+
18
+ const Title = ( { className, children }: DrawerTitleProps ) => {
19
+ const { setTitle } = useDrawerContext();
20
+ const id = useInstanceId( Title, 'newspack-drawer__title' ) as string;
21
+
22
+ // Derived, not keyed on `children`: a JSX title would re-register every render.
23
+ const text = typeof children === 'string' ? children : null;
24
+
25
+ useLayoutEffect( () => {
26
+ setTitle( { id, text } );
27
+ }, [ id, text, setTitle ] );
28
+
29
+ // Unmount only, scoped to this id: a text change must not re-order the stack,
30
+ // nor a second title unname the one still rendered.
31
+ useLayoutEffect( () => () => setTitle( null, id ), [ id, setTitle ] );
32
+
33
+ return (
34
+ <h2 className={ classnames( 'newspack-drawer__title', className ) } id={ id }>
35
+ { children }
36
+ </h2>
37
+ );
38
+ };
39
+
40
+ export default Title;
@@ -0,0 +1,79 @@
1
+ export type DrawerSize = 'small' | 'medium' | 'large' | 'x-large' | 'full';
2
+
3
+ /** `text` is null unless the title's children are a plain string. */
4
+ export type DrawerTitleInfo = {
5
+ id: string;
6
+ text: string | null;
7
+ };
8
+
9
+ export type DrawerRootProps = {
10
+ isOpen?: boolean;
11
+ size?: DrawerSize;
12
+ onRequestClose: () => void;
13
+ isDirty?: boolean;
14
+ confirmCloseMessage?: React.ReactNode;
15
+ confirmButtonText?: string;
16
+ requestConfirm?: ( callback: () => void ) => void;
17
+ contentLabel?: string;
18
+ describedBy?: string;
19
+ className?: string;
20
+ /** Merged into the frame's own style. A `ref` reaches the overlay around it. */
21
+ style?: React.CSSProperties;
22
+ children?: React.ReactNode;
23
+ };
24
+
25
+ export type DrawerHeaderProps = {
26
+ className?: string;
27
+ children?: React.ReactNode;
28
+ };
29
+
30
+ export type DrawerTitleProps = {
31
+ className?: string;
32
+ children?: React.ReactNode;
33
+ };
34
+
35
+ export type DrawerCloseIconProps = {
36
+ label?: string;
37
+ icon?: JSX.Element;
38
+ className?: string;
39
+ };
40
+
41
+ export type DrawerContentProps = {
42
+ /** 4px scale, as VStack's `spacing`. `0` for a flush section. */
43
+ padding?: number;
44
+ /** 4px scale, as VStack's `spacing`. */
45
+ gap?: number;
46
+ className?: string;
47
+ children?: React.ReactNode;
48
+ };
49
+
50
+ export type DrawerDividerProps = {
51
+ className?: string;
52
+ };
53
+
54
+ export type DrawerBodyProps = {
55
+ children?: React.ReactNode;
56
+ };
57
+
58
+ export type DrawerFooterProps = {
59
+ className?: string;
60
+ children?: React.ReactNode;
61
+ };
62
+
63
+ /** `ariaLabel` extends the visible label and must contain it (WCAG 2.5.3, Label in Name). */
64
+ type DrawerActionBaseProps = {
65
+ ariaLabel?: string;
66
+ variant?: 'primary' | 'secondary' | 'tertiary' | 'link';
67
+ disabled?: boolean;
68
+ isBusy?: boolean;
69
+ isDestructive?: boolean;
70
+ className?: string;
71
+ children?: React.ReactNode;
72
+ };
73
+
74
+ /** An action either navigates or acts: `href` cannot close the drawer or run a handler. */
75
+ export type DrawerActionProps = DrawerActionBaseProps &
76
+ (
77
+ | { href: string; onClick?: never; closes?: never }
78
+ | { href?: null; onClick?: ( event?: React.MouseEvent< HTMLElement > ) => void; closes?: boolean }
79
+ );
@@ -0,0 +1,46 @@
1
+ /**
2
+ * WordPress dependencies.
3
+ */
4
+ import { useReducedMotion } from '@wordpress/compose';
5
+ import { useEffect, useState } from '@wordpress/element';
6
+
7
+ // Covers an animationend that never arrives.
8
+ const EXIT_FALLBACK_MS = 400;
9
+
10
+ function useExitAnimation( isOpen: boolean, node: HTMLElement | null ) {
11
+ const [ isExiting, setIsExiting ] = useState( false );
12
+ const [ wasOpen, setWasOpen ] = useState( isOpen );
13
+
14
+ // The stylesheet zeroes the animation here, so animationend never fires and the
15
+ // fallback would hold a visible panel for 400ms.
16
+ const prefersReducedMotion = useReducedMotion();
17
+
18
+ // During render: a commit later the panel would leave the DOM and remount.
19
+ if ( wasOpen !== isOpen ) {
20
+ setWasOpen( isOpen );
21
+ setIsExiting( ! isOpen && ! prefersReducedMotion );
22
+ }
23
+
24
+ useEffect( () => {
25
+ if ( ! isExiting ) {
26
+ return;
27
+ }
28
+ // Keyed on the name: animationend bubbles from anything else animating.
29
+ const finish = ( event?: AnimationEvent ) => {
30
+ if ( event?.animationName && 'newspack-drawer-slide-out' !== event.animationName ) {
31
+ return;
32
+ }
33
+ setIsExiting( false );
34
+ };
35
+ const timer = setTimeout( finish, EXIT_FALLBACK_MS );
36
+ node?.addEventListener( 'animationend', finish );
37
+ return () => {
38
+ clearTimeout( timer );
39
+ node?.removeEventListener( 'animationend', finish );
40
+ };
41
+ }, [ isExiting, node ] );
42
+
43
+ return { isRendered: isOpen || isExiting, isExiting };
44
+ }
45
+
46
+ export default useExitAnimation;
@@ -23,6 +23,7 @@ type UseConfirmDialogOptions = {
23
23
  type UseConfirmDialogResult = {
24
24
  confirmDialog: React.ReactElement;
25
25
  requestConfirm: ( callback: () => void ) => void;
26
+ cancelConfirm: () => void;
26
27
  };
27
28
 
28
29
  /**
@@ -40,6 +41,9 @@ type UseConfirmDialogResult = {
40
41
  *
41
42
  * The `confirmDialog` element must be rendered somewhere in the component's
42
43
  * JSX for the dialog to appear.
44
+ *
45
+ * `cancelConfirm()` withdraws a pending request without invoking its callback,
46
+ * for when whatever raised the prompt goes away on its own.
43
47
  */
44
48
  function useConfirmDialog( options: UseConfirmDialogOptions ): UseConfirmDialogResult {
45
49
  const { message, when, ...dialogProps } = options;
@@ -59,14 +63,21 @@ function useConfirmDialog( options: UseConfirmDialogOptions ): UseConfirmDialogR
59
63
  }
60
64
  }, [] );
61
65
 
66
+ const cancelConfirm = useCallback( () => setPendingAction( null ), [] );
67
+
62
68
  const confirmDialog = (
63
69
  <ConfirmDialog
64
70
  { ...dialogProps }
65
71
  when={ when }
66
72
  isOpen={ !! pendingAction }
67
73
  onConfirm={ () => {
68
- pendingAction?.();
69
- setPendingAction( null );
74
+ // Cleared either way: a throwing callback would otherwise leave the
75
+ // dialog permanently unraisable for this instance.
76
+ try {
77
+ pendingAction?.();
78
+ } finally {
79
+ setPendingAction( null );
80
+ }
70
81
  } }
71
82
  onCancel={ () => setPendingAction( null ) }
72
83
  >
@@ -74,7 +85,7 @@ function useConfirmDialog( options: UseConfirmDialogOptions ): UseConfirmDialogR
74
85
  </ConfirmDialog>
75
86
  );
76
87
 
77
- return { confirmDialog, requestConfirm };
88
+ return { confirmDialog, requestConfirm, cancelConfirm };
78
89
  }
79
90
 
80
91
  export default useConfirmDialog;
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * WordPress dependencies.
3
3
  */
4
- import { useEffect, useRef } from '@wordpress/element';
4
+ import { useCallback, useEffect, useRef } from '@wordpress/element';
5
5
  import { __ } from '@wordpress/i18n';
6
6
 
7
7
  /**
@@ -39,17 +39,20 @@ function isSameOriginNavigation( link: HTMLAnchorElement ): boolean {
39
39
  * messaging, intercepts same-origin link clicks so the dialog fires instead
40
40
  * of a silent navigation, and adds a `beforeunload` listener as the last-resort
41
41
  * guard for refresh / tab-close (browser-native, cannot be styled). The
42
- * returned `confirmDialog` element must be rendered in JSX.
42
+ * returned `confirmDialog` element must be rendered in JSX, and `requestConfirm`
43
+ * and `cancelConfirm` behave as `useConfirmDialog` documents them.
43
44
  *
44
45
  * Single-consumer constraint: the click handler is attached at the document
45
46
  * level in capture phase. Two simultaneously-active instances will both fire
46
47
  * a dialog. A development-only warning surfaces this.
47
48
  */
48
49
  function useUnsavedChangesDialog( { when }: UseUnsavedChangesDialogOptions ) {
49
- const { confirmDialog, requestConfirm } = useConfirmDialog( {
50
+ const { confirmDialog, requestConfirm, cancelConfirm } = useConfirmDialog( {
50
51
  when,
51
52
  message: __( 'You have unsaved changes that will be lost. Discard changes?', 'newspack-plugin' ),
52
- confirmButtonText: __( 'Discard changes', 'newspack-plugin' ),
53
+ confirmButtonText: __( 'Discard Changes', 'newspack-plugin' ),
54
+ // Hidden, but still the dialog's accessible name.
55
+ title: __( 'Unsaved changes', 'newspack-plugin' ),
53
56
  hideTitle: true,
54
57
  } );
55
58
 
@@ -134,7 +137,16 @@ function useUnsavedChangesDialog( { when }: UseUnsavedChangesDialogOptions ) {
134
137
  return () => window.removeEventListener( 'beforeunload', handler );
135
138
  }, [ when ] );
136
139
 
137
- return { confirmDialog, requestConfirm };
140
+ // Escape hatch for an action that navigates on its own — a handoff that
141
+ // POSTs and then assigns `window.location` — once the dialog has already
142
+ // been confirmed. Left armed, the beforeunload guard would draw a second,
143
+ // native prompt on top of it. Call it immediately before navigating, so a
144
+ // failed action leaves the guard in place.
145
+ const allowNextUnload = useCallback( () => {
146
+ isNavigatingRef.current = true;
147
+ }, [] );
148
+
149
+ return { confirmDialog, requestConfirm, cancelConfirm, allowNextUnload };
138
150
  }
139
151
 
140
152
  export default useUnsavedChangesDialog;
package/src/index.js CHANGED
@@ -19,6 +19,7 @@ export { default as ConfirmDialog } from './confirm-dialog';
19
19
  export { default as CustomSelectControl } from './custom-select-control';
20
20
  export { default as DataViews } from './dataviews';
21
21
  export { default as Divider } from './divider';
22
+ export { default as Drawer } from './drawer';
22
23
  export { default as FormTokenField } from './form-token-field';
23
24
  export { default as Footer } from './footer';
24
25
  export { default as Handoff } from './handoff';
@@ -31,6 +32,7 @@ export { default as Modal } from './modal';
31
32
  export { default as NewspackIcon } from './newspack-icon';
32
33
  export { default as Notice } from './notice';
33
34
  export { default as Page } from './page';
35
+ export { default as PageControl } from './page-control';
34
36
  export { default as PluginInstaller } from './plugin-installer';
35
37
  export { default as PluginSettings } from './plugin-settings';
36
38
  export { default as PluginToggle } from './plugin-toggle';
@@ -44,10 +46,12 @@ export { default as StepsList } from './steps-list';
44
46
  export { default as StepsListItem } from './steps-list-item';
45
47
  export { default as StyleCard } from './style-card';
46
48
  export { default as TabbedNavigation } from './tabbed-navigation';
49
+ export { default as TableCard } from './table-card';
47
50
  export { default as TextControl } from './text-control';
48
51
  export { default as Waiting } from './waiting';
49
52
  export { default as WebPreview } from './web-preview';
50
53
  export { default as Wizard } from './wizard';
54
+ export { WizardBanner } from './wizard';
51
55
  export { default as withWizard } from './with-wizard';
52
56
  export { default as withWizardScreen } from './with-wizard-screen';
53
57
 
package/src/page/index.js CHANGED
@@ -39,7 +39,7 @@ import './style.scss';
39
39
  * inside its active tab panel — or, when no visible tab
40
40
  * owns the route, as a sibling of the panels.
41
41
  * @param {string} [props.className]
42
- * @param {*} props.children
42
+ * @param {*} [props.children]
43
43
  * @return {JSX.Element} Page component.
44
44
  */
45
45
  const Page = ( { breadcrumbItems = [], badges, subTitle, actions, tabbedNavigation, className, children } ) => {
@@ -0,0 +1,63 @@
1
+ /**
2
+ * Page Control.
3
+ *
4
+ * A single-page selector backed by an autocomplete search, for settings that point at a
5
+ * page without enumerating every page the site has.
6
+ */
7
+
8
+ /**
9
+ * WordPress dependencies
10
+ */
11
+ import { useEffect, useState } from '@wordpress/element';
12
+ import { decodeEntities } from '@wordpress/html-entities';
13
+ import { __ } from '@wordpress/i18n';
14
+
15
+ /**
16
+ * Internal dependencies
17
+ */
18
+ import { AutocompleteWithSuggestions } from '../';
19
+
20
+ /**
21
+ * Render a single-page selector.
22
+ *
23
+ * @param {Object} props Component props.
24
+ * @param {string} props.label Field label.
25
+ * @param {string} props.help Help text.
26
+ * @param {boolean} props.disabled Whether the field is disabled (e.g. while saving).
27
+ * @param {Object} props.selected The saved page as `{ label, value }`, if any.
28
+ * @param {Function} props.onChange Called with the selected page ID, or '' when cleared.
29
+ */
30
+ const PageControl = ( { label, help, disabled, selected, onChange } ) => {
31
+ // Titles arrive HTML-encoded from the server, while the autocomplete decodes the
32
+ // suggestions it fetches. Decode here so the saved page and the search results below
33
+ // it render the same way.
34
+ const normalize = item => ( item ? { ...item, label: decodeEntities( item.label ) } : null );
35
+ const [ selectedItem, setSelectedItem ] = useState( normalize( selected ) );
36
+ // Settings are fetched after this control first renders, so adopt the saved page
37
+ // whenever it arrives or changes rather than only on mount.
38
+ useEffect( () => {
39
+ setSelectedItem( normalize( selected ) );
40
+ }, [ selected?.value, selected?.label ] );
41
+ return (
42
+ <fieldset disabled={ disabled } className="newspack-page-control">
43
+ <AutocompleteWithSuggestions
44
+ label={ label }
45
+ help={ help }
46
+ postTypes={ [ { slug: 'page', label: __( 'Page', 'newspack-plugin' ) } ] }
47
+ postTypeLabel={ __( 'page', 'newspack-plugin' ) }
48
+ postTypeLabelPlural={ __( 'pages', 'newspack-plugin' ) }
49
+ selectedItems={ selectedItem ? [ selectedItem ] : [] }
50
+ onChange={ items => {
51
+ if ( disabled ) {
52
+ return;
53
+ }
54
+ const item = items && items.length ? items[ items.length - 1 ] : null;
55
+ setSelectedItem( normalize( item ) );
56
+ onChange( item ? String( item.value ) : '' );
57
+ } }
58
+ />
59
+ </fieldset>
60
+ );
61
+ };
62
+
63
+ export default PageControl;
@@ -13,13 +13,17 @@ import { CheckboxControl } from '@wordpress/components';
13
13
  /**
14
14
  * Internal dependencies
15
15
  */
16
- import { ActionCard, Button, Grid, Notice, SelectControl, TextControl } from '../';
16
+ import { ActionCard, Button, Grid, Notice, PageControl, SelectControl, TextControl } from '../';
17
17
  import './style.scss';
18
18
 
19
19
  const isSelectControl = setting => {
20
20
  return Array.isArray( setting.options ) && setting.options.length;
21
21
  };
22
22
  const getControlComponent = setting => {
23
+ // An explicit `control` overrides the control implied by the setting's data type.
24
+ if ( 'page' === setting.control ) {
25
+ return PageControl;
26
+ }
23
27
  if ( isSelectControl( setting ) ) {
24
28
  return SelectControl;
25
29
  }
@@ -66,6 +70,8 @@ const SettingsSection = props => {
66
70
  label: option.name || option.label,
67
71
  } ) ) || null,
68
72
  value: setting.value,
73
+ // Only page controls consume `selected`; keep it off every other control's props.
74
+ selected: 'page' === setting.control ? setting.selected || null : null,
69
75
  multiple: isSelectControl( setting ) && setting.multiple ? true : null,
70
76
  checked: setting.type === 'boolean' ? !! setting.value : null,
71
77
  onChange: value => {
@@ -104,7 +110,7 @@ const SettingsSection = props => {
104
110
  setSaveDisabled( true );
105
111
  } }
106
112
  >
107
- { __( 'Save Settings', 'newspack' ) }
113
+ { __( 'Save Settings', 'newspack-plugin' ) }
108
114
  </Button>
109
115
  )
110
116
  }
@@ -36,6 +36,7 @@ import classnames from 'classnames';
36
36
  * @property {boolean} [isWhite=false] - Indicates if the header should use a white theme.
37
37
  * @property {boolean} [noMargin=false] - Indicates if the header should have no margin.
38
38
  * @property {boolean} [pageHeader=false] - Indicates if the header is used as a page header.
39
+ * @property {string} [size='default'] - Size variant, either 'default' or 'small'. Scales the title, and the icon with it, independently of `pageHeader`.
39
40
  * @property {string} title - The title of the section.
40
41
  * @property {?string} [id=null] - Optional ID for the header element.
41
42
  * @property {?string|Function|*} [children=null] - Optional children to display in the header.
@@ -57,6 +58,7 @@ const SectionHeader = ( {
57
58
  isWhite = false,
58
59
  noMargin = false,
59
60
  pageHeader = false,
61
+ size = 'default',
60
62
  title,
61
63
  id = null,
62
64
  menu,
@@ -82,7 +84,8 @@ const SectionHeader = ( {
82
84
  centered && 'newspack-section-header--is-centered',
83
85
  isWhite && 'newspack-section-header--is-white',
84
86
  noMargin && 'newspack-section-header--no-margin',
85
- pageHeader && 'newspack-section-header--page-header'
87
+ pageHeader && 'newspack-section-header--page-header',
88
+ size === 'small' && 'newspack-section-header--small'
86
89
  );
87
90
 
88
91
  // The breadcrumb `Page` owns the single page `<h1>`, so a `pageHeader` section
@@ -148,7 +151,7 @@ const SectionHeader = ( {
148
151
  <Grid columns={ 1 } gutter={ 8 } className={ classes }>
149
152
  { icon && (
150
153
  <div className="newspack-section-header__icon">
151
- <Icon icon={ icon } size={ 48 } />
154
+ <Icon icon={ icon } size={ size === 'small' ? 24 : 48 } />
152
155
  </div>
153
156
  ) }
154
157
  { backNav ? (
@@ -95,6 +95,31 @@
95
95
  @include wp-mixins.heading-x-large();
96
96
  }
97
97
 
98
+ &#{&}--small {
99
+ .newspack-section-header__title {
100
+ @include wp-mixins.heading-large();
101
+ }
102
+ }
103
+
104
+ &__icon {
105
+ background-color: var(--wp-admin-theme-color-lighter-10);
106
+ border-radius: 100%;
107
+ display: grid;
108
+ height: 80px;
109
+ margin-bottom: 8px !important;
110
+ width: 80px;
111
+ place-items: center;
112
+ svg {
113
+ fill: var(--wp-admin-theme-color);
114
+ }
115
+ }
116
+
117
+ &#{&}--small .newspack-section-header__icon {
118
+ height: wp-vars.$grid-unit-50;
119
+ margin-bottom: wp-vars.$grid-unit-05 !important;
120
+ width: wp-vars.$grid-unit-50;
121
+ }
122
+
98
123
  &__title-container {
99
124
  align-items: center;
100
125
  display: flex;
@@ -154,17 +179,5 @@
154
179
  > p {
155
180
  text-align: center;
156
181
  }
157
- .newspack-section-header__icon {
158
- background-color: var(--wp-admin-theme-color-lighter-10);
159
- border-radius: 100%;
160
- display: grid;
161
- height: 80px;
162
- margin-bottom: 8px !important;
163
- width: 80px;
164
- place-items: center;
165
- svg {
166
- fill: var(--wp-admin-theme-color);
167
- }
168
- }
169
182
  }
170
183
  }