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.
- package/CHANGELOG.md +23 -0
- package/colors/colors.module.scss +13 -7
- package/dist/cjs/autocomplete-tokenfield/style.scss +4 -2
- package/dist/cjs/breadcrumbs/format-count.js +33 -0
- package/dist/cjs/breadcrumbs/index.js +78 -20
- package/dist/cjs/breadcrumbs/index.test.js +110 -0
- package/dist/cjs/breadcrumbs/style.scss +6 -0
- package/dist/cjs/button/index.js +41 -17
- package/dist/cjs/button/index.test.js +273 -0
- package/dist/cjs/card/core-card.js +9 -4
- package/dist/cjs/card/core-card.test.js +29 -0
- package/dist/cjs/card/index.js +4 -2
- package/dist/cjs/card/style-core.scss +59 -0
- package/dist/cjs/card-feature/index.js +7 -1
- package/dist/cjs/card-feature/index.test.js +92 -0
- package/dist/cjs/color-picker/index.js +1 -1
- package/dist/cjs/confirm-dialog/index.js +26 -9
- package/dist/cjs/confirm-dialog/index.test.js +176 -0
- package/dist/cjs/dataviews/index.js +11 -2
- package/dist/cjs/divider/index.js +13 -0
- package/dist/cjs/drawer/action.js +74 -0
- package/dist/cjs/drawer/body.js +79 -0
- package/dist/cjs/drawer/close-icon.js +45 -0
- package/dist/cjs/drawer/content.js +72 -0
- package/dist/cjs/drawer/context.js +23 -0
- package/dist/cjs/drawer/context.test.js +43 -0
- package/dist/cjs/drawer/divider.js +24 -0
- package/dist/cjs/drawer/footer.js +26 -0
- package/dist/cjs/drawer/header.js +26 -0
- package/dist/cjs/drawer/index.js +42 -0
- package/dist/cjs/drawer/index.test.js +1287 -0
- package/dist/cjs/drawer/root.js +350 -0
- package/dist/cjs/drawer/slots.test.js +422 -0
- package/dist/cjs/drawer/style.scss +205 -0
- package/dist/cjs/drawer/title.js +54 -0
- package/dist/cjs/drawer/types.js +5 -0
- package/dist/cjs/drawer/use-exit-animation.js +59 -0
- package/dist/cjs/hooks/use-confirm-dialog.js +15 -3
- package/dist/cjs/hooks/use-unsaved-changes-dialog.js +19 -4
- package/dist/cjs/index.js +28 -1
- package/dist/cjs/page/index.js +1 -1
- package/dist/cjs/page-control/index.js +82 -0
- package/dist/cjs/plugin-settings/SettingsSection.js +7 -1
- package/dist/cjs/section-header/index.js +5 -2
- package/dist/cjs/section-header/style.scss +25 -12
- package/dist/cjs/table-card/index.js +67 -0
- package/dist/cjs/table-card/index.test.js +153 -0
- package/dist/cjs/with-wizard-screen/style.scss +8 -0
- package/dist/cjs/wizard/index.js +102 -38
- package/dist/esm/autocomplete-tokenfield/style.scss +4 -2
- package/dist/esm/breadcrumbs/format-count.js +27 -0
- package/dist/esm/breadcrumbs/index.js +81 -20
- package/dist/esm/breadcrumbs/index.test.js +110 -0
- package/dist/esm/breadcrumbs/style.scss +6 -0
- package/dist/esm/button/index.js +42 -18
- package/dist/esm/button/index.test.js +273 -0
- package/dist/esm/card/core-card.js +9 -4
- package/dist/esm/card/core-card.test.js +29 -0
- package/dist/esm/card/index.js +4 -2
- package/dist/esm/card/style-core.scss +59 -0
- package/dist/esm/card-feature/index.js +8 -2
- package/dist/esm/card-feature/index.test.js +96 -0
- package/dist/esm/color-picker/index.js +1 -1
- package/dist/esm/confirm-dialog/index.js +26 -9
- package/dist/esm/confirm-dialog/index.test.js +176 -0
- package/dist/esm/dataviews/index.js +11 -2
- package/dist/esm/divider/index.js +14 -0
- package/dist/esm/drawer/action.js +68 -0
- package/dist/esm/drawer/body.js +70 -0
- package/dist/esm/drawer/close-icon.js +37 -0
- package/dist/esm/drawer/content.js +63 -0
- package/dist/esm/drawer/context.js +17 -0
- package/dist/esm/drawer/context.test.js +40 -0
- package/dist/esm/drawer/divider.js +16 -0
- package/dist/esm/drawer/footer.js +18 -0
- package/dist/esm/drawer/header.js +18 -0
- package/dist/esm/drawer/index.js +35 -0
- package/dist/esm/drawer/index.test.js +1285 -0
- package/dist/esm/drawer/root.js +342 -0
- package/dist/esm/drawer/slots.test.js +422 -0
- package/dist/esm/drawer/style.scss +205 -0
- package/dist/esm/drawer/title.js +46 -0
- package/dist/esm/drawer/types.js +1 -0
- package/dist/esm/drawer/use-exit-animation.js +52 -0
- package/dist/esm/hooks/use-confirm-dialog.js +15 -3
- package/dist/esm/hooks/use-unsaved-changes-dialog.js +20 -5
- package/dist/esm/index.js +4 -0
- package/dist/esm/page/index.js +1 -1
- package/dist/esm/page-control/index.js +82 -0
- package/dist/esm/plugin-settings/SettingsSection.js +8 -2
- package/dist/esm/section-header/index.js +5 -2
- package/dist/esm/section-header/style.scss +25 -12
- package/dist/esm/table-card/index.js +60 -0
- package/dist/esm/table-card/index.test.js +150 -0
- package/dist/esm/with-wizard-screen/style.scss +8 -0
- package/dist/esm/wizard/index.js +104 -38
- package/package.json +2 -2
- package/src/autocomplete-tokenfield/style.scss +4 -2
- package/src/breadcrumbs/format-count.js +27 -0
- package/src/breadcrumbs/index.js +68 -8
- package/src/breadcrumbs/index.test.js +57 -0
- package/src/breadcrumbs/style.scss +6 -0
- package/src/button/index.test.js +143 -0
- package/src/button/index.tsx +32 -7
- package/src/card/core-card.js +15 -2
- package/src/card/core-card.test.js +19 -0
- package/src/card/index.js +3 -0
- package/src/card/style-core.scss +59 -0
- package/src/card-feature/README.md +4 -0
- package/src/card-feature/index.test.js +65 -0
- package/src/card-feature/index.tsx +13 -2
- package/src/color-picker/index.js +1 -1
- package/src/confirm-dialog/README.md +3 -2
- package/src/confirm-dialog/index.test.js +146 -0
- package/src/confirm-dialog/index.tsx +23 -9
- package/src/dataviews/index.tsx +11 -2
- package/src/divider/index.js +13 -0
- package/src/drawer/README.md +454 -0
- package/src/drawer/action.tsx +67 -0
- package/src/drawer/body.tsx +60 -0
- package/src/drawer/close-icon.tsx +37 -0
- package/src/drawer/content.tsx +54 -0
- package/src/drawer/context.test.js +31 -0
- package/src/drawer/context.ts +25 -0
- package/src/drawer/divider.tsx +13 -0
- package/src/drawer/footer.tsx +15 -0
- package/src/drawer/header.tsx +15 -0
- package/src/drawer/index.test.js +1012 -0
- package/src/drawer/index.tsx +33 -0
- package/src/drawer/root.tsx +328 -0
- package/src/drawer/slots.test.js +314 -0
- package/src/drawer/style.scss +205 -0
- package/src/drawer/title.tsx +40 -0
- package/src/drawer/types.ts +79 -0
- package/src/drawer/use-exit-animation.ts +46 -0
- package/src/hooks/use-confirm-dialog.tsx +14 -3
- package/src/hooks/use-unsaved-changes-dialog.tsx +17 -5
- package/src/index.js +4 -0
- package/src/page/index.js +1 -1
- package/src/page-control/index.js +63 -0
- package/src/plugin-settings/SettingsSection.js +8 -2
- package/src/section-header/index.js +5 -2
- package/src/section-header/style.scss +25 -12
- package/src/table-card/README.md +100 -0
- package/src/table-card/index.test.js +112 -0
- package/src/table-card/index.tsx +56 -0
- package/src/with-wizard-screen/style.scss +8 -0
- package/src/wizard/index.js +95 -34
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Drawer
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Internal dependencies.
|
|
7
|
+
*/
|
|
8
|
+
import Action from './action';
|
|
9
|
+
import CloseIcon from './close-icon';
|
|
10
|
+
import Content from './content';
|
|
11
|
+
import Footer from './footer';
|
|
12
|
+
import Header from './header';
|
|
13
|
+
import Root from './root';
|
|
14
|
+
import Divider from './divider';
|
|
15
|
+
import Title from './title';
|
|
16
|
+
|
|
17
|
+
// A namespace object, not this package's usual flat exports. See the README.
|
|
18
|
+
export const Drawer = {
|
|
19
|
+
Root,
|
|
20
|
+
Header,
|
|
21
|
+
Title,
|
|
22
|
+
CloseIcon,
|
|
23
|
+
Content,
|
|
24
|
+
Divider,
|
|
25
|
+
Footer,
|
|
26
|
+
Action,
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
Object.entries( Drawer ).forEach( ( [ name, part ] ) => {
|
|
30
|
+
( part as { displayName?: string } ).displayName = `Drawer.${ name }`;
|
|
31
|
+
} );
|
|
32
|
+
|
|
33
|
+
export default Drawer;
|
|
@@ -0,0 +1,328 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WordPress dependencies.
|
|
3
|
+
*/
|
|
4
|
+
import { Modal, Popover, SlotFillProvider } from '@wordpress/components';
|
|
5
|
+
import { useMergeRefs } from '@wordpress/compose';
|
|
6
|
+
import { Children, forwardRef, isValidElement, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from '@wordpress/element';
|
|
7
|
+
import { __ } from '@wordpress/i18n';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* External dependencies.
|
|
11
|
+
*/
|
|
12
|
+
import classnames from 'classnames';
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Internal dependencies.
|
|
16
|
+
*/
|
|
17
|
+
import useConfirmDialog from '../hooks/use-confirm-dialog';
|
|
18
|
+
import { DrawerContext } from './context';
|
|
19
|
+
import Body from './body';
|
|
20
|
+
import Content from './content';
|
|
21
|
+
import Divider from './divider';
|
|
22
|
+
import useExitAnimation from './use-exit-animation';
|
|
23
|
+
import type { DrawerRootProps, DrawerTitleInfo } from './types';
|
|
24
|
+
import './style.scss';
|
|
25
|
+
|
|
26
|
+
const sizeClassMap = {
|
|
27
|
+
small: 'newspack-drawer--size-small',
|
|
28
|
+
medium: 'newspack-drawer--size-medium',
|
|
29
|
+
large: 'newspack-drawer--size-large',
|
|
30
|
+
'x-large': 'newspack-drawer--size-x-large',
|
|
31
|
+
full: 'newspack-drawer--size-full',
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
const captureOptions = { capture: true };
|
|
35
|
+
|
|
36
|
+
const Root = forwardRef< HTMLDivElement, DrawerRootProps >( function Root(
|
|
37
|
+
{
|
|
38
|
+
isOpen = false,
|
|
39
|
+
size = 'medium',
|
|
40
|
+
onRequestClose,
|
|
41
|
+
isDirty = false,
|
|
42
|
+
confirmCloseMessage = __( 'You have unsaved changes that will be lost. Discard changes?', 'newspack-plugin' ),
|
|
43
|
+
confirmButtonText = __( 'Discard Changes', 'newspack-plugin' ),
|
|
44
|
+
requestConfirm,
|
|
45
|
+
contentLabel,
|
|
46
|
+
describedBy,
|
|
47
|
+
className,
|
|
48
|
+
style,
|
|
49
|
+
children,
|
|
50
|
+
},
|
|
51
|
+
forwardedRef
|
|
52
|
+
) {
|
|
53
|
+
const [ title, setTitle ] = useState< DrawerTitleInfo | null >( null );
|
|
54
|
+
|
|
55
|
+
const titleRef = useRef< DrawerTitleInfo | null >( null );
|
|
56
|
+
const titlesRef = useRef< DrawerTitleInfo[] >( [] );
|
|
57
|
+
const registerTitle = useCallback( ( info: DrawerTitleInfo | null, forId?: string ) => {
|
|
58
|
+
if ( info ) {
|
|
59
|
+
const at = titlesRef.current.findIndex( entry => entry.id === info.id );
|
|
60
|
+
if ( at === -1 ) {
|
|
61
|
+
titlesRef.current = [ ...titlesRef.current, info ];
|
|
62
|
+
} else {
|
|
63
|
+
titlesRef.current = titlesRef.current.map( entry => ( entry.id === info.id ? info : entry ) );
|
|
64
|
+
}
|
|
65
|
+
} else if ( forId ) {
|
|
66
|
+
titlesRef.current = titlesRef.current.filter( entry => entry.id !== forId );
|
|
67
|
+
} else {
|
|
68
|
+
titlesRef.current = [];
|
|
69
|
+
}
|
|
70
|
+
const current = titlesRef.current[ titlesRef.current.length - 1 ] ?? null;
|
|
71
|
+
titleRef.current = current;
|
|
72
|
+
setTitle( current );
|
|
73
|
+
}, [] );
|
|
74
|
+
|
|
75
|
+
useEffect( () => {
|
|
76
|
+
if ( 'production' === process.env.NODE_ENV || ! isOpen ) {
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
if ( ! titleRef.current && ! contentLabel ) {
|
|
80
|
+
// eslint-disable-next-line no-console
|
|
81
|
+
console.warn( 'Drawer: render a Drawer.Title or pass `contentLabel`. Without one the panel has no accessible name.' );
|
|
82
|
+
}
|
|
83
|
+
}, [ isOpen, title, contentLabel ] );
|
|
84
|
+
|
|
85
|
+
useEffect( () => {
|
|
86
|
+
if ( 'production' === process.env.NODE_ENV || sizeClassMap[ size ] ) {
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
// eslint-disable-next-line no-console
|
|
90
|
+
console.warn( `Drawer: unknown size "${ size }", falling back to medium. Use one of ${ Object.keys( sizeClassMap ).join( ', ' ) }.` );
|
|
91
|
+
}, [ size ] );
|
|
92
|
+
|
|
93
|
+
// No `when`: it installs a history.block guard that fights the wizard's own.
|
|
94
|
+
const {
|
|
95
|
+
confirmDialog,
|
|
96
|
+
requestConfirm: ownRequestConfirm,
|
|
97
|
+
cancelConfirm: ownCancelConfirm,
|
|
98
|
+
} = useConfirmDialog( {
|
|
99
|
+
message: confirmCloseMessage,
|
|
100
|
+
confirmButtonText,
|
|
101
|
+
// Hidden, but still the dialog's accessible name.
|
|
102
|
+
title: __( 'Unsaved changes', 'newspack-plugin' ),
|
|
103
|
+
hideTitle: true,
|
|
104
|
+
} );
|
|
105
|
+
|
|
106
|
+
const confirm = requestConfirm || ownRequestConfirm;
|
|
107
|
+
|
|
108
|
+
const requestClose = useCallback( () => {
|
|
109
|
+
if ( ! isOpen ) {
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
if ( isDirty ) {
|
|
113
|
+
let prompted = true;
|
|
114
|
+
confirm( () => {
|
|
115
|
+
prompted = false;
|
|
116
|
+
onRequestClose();
|
|
117
|
+
} );
|
|
118
|
+
if ( 'production' !== process.env.NODE_ENV && ! prompted ) {
|
|
119
|
+
// eslint-disable-next-line no-console
|
|
120
|
+
console.warn(
|
|
121
|
+
'Drawer: `requestConfirm` closed the panel without prompting, so `isDirty` is set while its own `when` is not. ' +
|
|
122
|
+
'Unsaved changes are being discarded silently. Drive both from the same state.'
|
|
123
|
+
);
|
|
124
|
+
}
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
onRequestClose();
|
|
128
|
+
}, [ isOpen, isDirty, confirm, onRequestClose ] );
|
|
129
|
+
|
|
130
|
+
// Core fills its dismisser ref in a passive effect and siblings call it from
|
|
131
|
+
// theirs, so a closure would go stale by tree order. Layout effects run first.
|
|
132
|
+
const dismissRef = useRef( { isOpen, isDirty, onRequestClose } );
|
|
133
|
+
useLayoutEffect( () => {
|
|
134
|
+
dismissRef.current = { isOpen, isDirty, onRequestClose };
|
|
135
|
+
} );
|
|
136
|
+
|
|
137
|
+
// Core calls this as another Modal mounts. A dirty panel cannot answer: its own
|
|
138
|
+
// confirmation would land under that Modal, and a delegated one is that Modal.
|
|
139
|
+
const dismiss = useCallback( () => {
|
|
140
|
+
const current = dismissRef.current;
|
|
141
|
+
if ( current.isOpen && ! current.isDirty ) {
|
|
142
|
+
current.onRequestClose();
|
|
143
|
+
}
|
|
144
|
+
}, [] );
|
|
145
|
+
|
|
146
|
+
const onKeyDown = useCallback(
|
|
147
|
+
( event: React.KeyboardEvent ) => {
|
|
148
|
+
// Cancelling a CJK composition raises an Escape of its own.
|
|
149
|
+
if ( event.nativeEvent?.isComposing || 229 === event.keyCode ) {
|
|
150
|
+
return;
|
|
151
|
+
}
|
|
152
|
+
if ( ( 'Escape' !== event.key && 'Escape' !== event.code ) || event.defaultPrevented ) {
|
|
153
|
+
return;
|
|
154
|
+
}
|
|
155
|
+
event.preventDefault();
|
|
156
|
+
requestClose();
|
|
157
|
+
},
|
|
158
|
+
[ requestClose ]
|
|
159
|
+
);
|
|
160
|
+
|
|
161
|
+
const [ node, setNode ] = useState< HTMLElement | null >( null );
|
|
162
|
+
const frameRefs = useMergeRefs( [ setNode, forwardedRef ] );
|
|
163
|
+
const { isRendered, isExiting } = useExitAnimation( isOpen, node );
|
|
164
|
+
|
|
165
|
+
const openerRef = useRef< Element | null >( null );
|
|
166
|
+
const [ wasOpenForFocus, setWasOpenForFocus ] = useState( false );
|
|
167
|
+
if ( wasOpenForFocus !== isOpen ) {
|
|
168
|
+
setWasOpenForFocus( isOpen );
|
|
169
|
+
if ( isOpen ) {
|
|
170
|
+
// eslint-disable-next-line @wordpress/no-global-active-element
|
|
171
|
+
openerRef.current = typeof document === 'undefined' ? null : document.activeElement;
|
|
172
|
+
} else {
|
|
173
|
+
// During render, so it is gone in the commit that starts the exit rather
|
|
174
|
+
// than holding focus while the panel goes inert. Only ours to withdraw.
|
|
175
|
+
ownCancelConfirm();
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
const movedFocusOut = useRef( false );
|
|
180
|
+
useEffect( () => {
|
|
181
|
+
const frame = node?.querySelector< HTMLElement >( '.components-modal__frame' );
|
|
182
|
+
if ( ! frame ) {
|
|
183
|
+
if ( node && 'production' !== process.env.NODE_ENV ) {
|
|
184
|
+
// eslint-disable-next-line no-console
|
|
185
|
+
console.warn(
|
|
186
|
+
'Drawer: no .components-modal__frame inside the overlay. Focus return and `inert` are both off. ' +
|
|
187
|
+
'Check this against the installed @wordpress/components.'
|
|
188
|
+
);
|
|
189
|
+
}
|
|
190
|
+
return;
|
|
191
|
+
}
|
|
192
|
+
if ( isExiting ) {
|
|
193
|
+
// Focus goes home before `inert` blurs it, unless parked elsewhere:
|
|
194
|
+
// reclaiming it from a sibling frame would take it from an open modal.
|
|
195
|
+
const active = frame.ownerDocument.activeElement;
|
|
196
|
+
if ( frame.contains( active ) || ! active || active === frame.ownerDocument.body ) {
|
|
197
|
+
movedFocusOut.current = true;
|
|
198
|
+
const opener = openerRef.current as HTMLElement | null;
|
|
199
|
+
if ( opener?.isConnected ) {
|
|
200
|
+
opener.focus?.();
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
frame.toggleAttribute( 'inert', true );
|
|
204
|
+
return;
|
|
205
|
+
}
|
|
206
|
+
frame.toggleAttribute( 'inert', false );
|
|
207
|
+
// Reopened mid-exit: the same Modal instance, so focus-on-mount never re-runs.
|
|
208
|
+
const restore = movedFocusOut.current;
|
|
209
|
+
movedFocusOut.current = false;
|
|
210
|
+
if ( restore && frame.isConnected && ! frame.contains( frame.ownerDocument.activeElement ) ) {
|
|
211
|
+
frame.focus( { preventScroll: true } );
|
|
212
|
+
}
|
|
213
|
+
}, [ node, isExiting ] );
|
|
214
|
+
|
|
215
|
+
// Reduced motion has no exit to hand focus back during.
|
|
216
|
+
useLayoutEffect( () => {
|
|
217
|
+
if ( ! isRendered ) {
|
|
218
|
+
return;
|
|
219
|
+
}
|
|
220
|
+
return () => {
|
|
221
|
+
const opener = openerRef.current as HTMLElement | null;
|
|
222
|
+
if ( ! opener?.isConnected ) {
|
|
223
|
+
return;
|
|
224
|
+
}
|
|
225
|
+
const active = opener.ownerDocument.activeElement;
|
|
226
|
+
if ( active && active !== opener.ownerDocument.body && active.isConnected ) {
|
|
227
|
+
return;
|
|
228
|
+
}
|
|
229
|
+
opener.focus?.();
|
|
230
|
+
};
|
|
231
|
+
}, [ isRendered ] );
|
|
232
|
+
|
|
233
|
+
const pressedOverlay = useRef( false );
|
|
234
|
+
// On commit, not after paint: a release in the gap would close a drawer that
|
|
235
|
+
// has just turned dirty.
|
|
236
|
+
const requestCloseRef = useRef( requestClose );
|
|
237
|
+
useLayoutEffect( () => {
|
|
238
|
+
requestCloseRef.current = requestClose;
|
|
239
|
+
}, [ requestClose ] );
|
|
240
|
+
useEffect( () => {
|
|
241
|
+
if ( ! node ) {
|
|
242
|
+
return;
|
|
243
|
+
}
|
|
244
|
+
const { ownerDocument } = node;
|
|
245
|
+
pressedOverlay.current = false;
|
|
246
|
+
const onPointerDown = ( event: PointerEvent ) => {
|
|
247
|
+
pressedOverlay.current = 0 === event.button && event.target === node;
|
|
248
|
+
if ( pressedOverlay.current ) {
|
|
249
|
+
// Touch and pen capture implicitly, reporting the release on the overlay.
|
|
250
|
+
if ( 'function' === typeof node.hasPointerCapture && node.hasPointerCapture( event.pointerId ) ) {
|
|
251
|
+
node.releasePointerCapture( event.pointerId );
|
|
252
|
+
}
|
|
253
|
+
event.preventDefault();
|
|
254
|
+
}
|
|
255
|
+
};
|
|
256
|
+
const onPointerUp = ( event: PointerEvent ) => {
|
|
257
|
+
const closes = pressedOverlay.current && 0 === event.button && event.target === node;
|
|
258
|
+
pressedOverlay.current = false;
|
|
259
|
+
if ( closes ) {
|
|
260
|
+
requestCloseRef.current();
|
|
261
|
+
}
|
|
262
|
+
};
|
|
263
|
+
const onPointerCancel = () => {
|
|
264
|
+
pressedOverlay.current = false;
|
|
265
|
+
};
|
|
266
|
+
node.addEventListener( 'pointerdown', onPointerDown );
|
|
267
|
+
ownerDocument.addEventListener( 'pointerup', onPointerUp, captureOptions );
|
|
268
|
+
ownerDocument.addEventListener( 'pointercancel', onPointerCancel, captureOptions );
|
|
269
|
+
return () => {
|
|
270
|
+
pressedOverlay.current = false;
|
|
271
|
+
node.removeEventListener( 'pointerdown', onPointerDown );
|
|
272
|
+
ownerDocument.removeEventListener( 'pointerup', onPointerUp, captureOptions );
|
|
273
|
+
ownerDocument.removeEventListener( 'pointercancel', onPointerCancel, captureOptions );
|
|
274
|
+
};
|
|
275
|
+
}, [ node ] );
|
|
276
|
+
|
|
277
|
+
const contextValue = useMemo( () => ( { requestClose, title, setTitle: registerTitle } ), [ requestClose, title, registerTitle ] );
|
|
278
|
+
|
|
279
|
+
if ( ! isRendered ) {
|
|
280
|
+
return null;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
const grouped: React.ReactNode[] = [];
|
|
284
|
+
let run: React.ReactNode[] = [];
|
|
285
|
+
let bodyIndex = 0;
|
|
286
|
+
const flushRun = () => {
|
|
287
|
+
if ( run.length ) {
|
|
288
|
+
grouped.push( <Body key={ `body-${ bodyIndex++ }` }>{ run }</Body> );
|
|
289
|
+
run = [];
|
|
290
|
+
}
|
|
291
|
+
};
|
|
292
|
+
Children.toArray( children ).forEach( child => {
|
|
293
|
+
if ( isValidElement( child ) && ( child.type === Content || child.type === Divider ) ) {
|
|
294
|
+
run.push( child );
|
|
295
|
+
} else {
|
|
296
|
+
flushRun();
|
|
297
|
+
grouped.push( child );
|
|
298
|
+
}
|
|
299
|
+
} );
|
|
300
|
+
flushRun();
|
|
301
|
+
|
|
302
|
+
return (
|
|
303
|
+
<DrawerContext.Provider value={ contextValue }>
|
|
304
|
+
<Modal
|
|
305
|
+
__experimentalHideHeader
|
|
306
|
+
ref={ frameRefs }
|
|
307
|
+
className={ classnames( 'newspack-drawer', sizeClassMap[ size ] || sizeClassMap.medium, isExiting && 'is-exiting', className ) }
|
|
308
|
+
overlayClassName={ classnames( 'newspack-drawer__overlay', isExiting && 'is-exiting' ) }
|
|
309
|
+
style={ style }
|
|
310
|
+
contentLabel={ title ? undefined : contentLabel }
|
|
311
|
+
aria={ { labelledby: title ? title.id : undefined, describedby: describedBy } }
|
|
312
|
+
shouldCloseOnEsc={ false }
|
|
313
|
+
shouldCloseOnClickOutside={ false }
|
|
314
|
+
onRequestClose={ dismiss }
|
|
315
|
+
onKeyDown={ onKeyDown }
|
|
316
|
+
>
|
|
317
|
+
{ /* Without a slot inside, a popover lands in an aria-hidden container. */ }
|
|
318
|
+
<SlotFillProvider>
|
|
319
|
+
{ grouped }
|
|
320
|
+
{ ! requestConfirm && confirmDialog }
|
|
321
|
+
<Popover.Slot />
|
|
322
|
+
</SlotFillProvider>
|
|
323
|
+
</Modal>
|
|
324
|
+
</DrawerContext.Provider>
|
|
325
|
+
);
|
|
326
|
+
} );
|
|
327
|
+
|
|
328
|
+
export default Root;
|
|
@@ -0,0 +1,314 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Internal dependencies.
|
|
3
|
+
*/
|
|
4
|
+
import { DrawerContext } from './context';
|
|
5
|
+
import Header from './header';
|
|
6
|
+
import Title from './title';
|
|
7
|
+
import CloseIcon from './close-icon';
|
|
8
|
+
import Content from './content';
|
|
9
|
+
import Divider from './divider';
|
|
10
|
+
import Footer from './footer';
|
|
11
|
+
import Action from './action';
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* External dependencies.
|
|
15
|
+
*/
|
|
16
|
+
import { fireEvent, render, screen } from '@testing-library/react';
|
|
17
|
+
|
|
18
|
+
const withContext = ( ui, value = {} ) =>
|
|
19
|
+
render( <DrawerContext.Provider value={ { requestClose: () => {}, title: null, setTitle: () => {}, ...value } }>{ ui }</DrawerContext.Provider> );
|
|
20
|
+
|
|
21
|
+
describe( 'Drawer.Header', () => {
|
|
22
|
+
it( 'renders a container with its children', () => {
|
|
23
|
+
withContext( <Header className="custom">content</Header> );
|
|
24
|
+
const header = screen.getByText( 'content' );
|
|
25
|
+
expect( header ).toHaveClass( 'newspack-drawer__header', 'custom' );
|
|
26
|
+
} );
|
|
27
|
+
} );
|
|
28
|
+
|
|
29
|
+
describe( 'Drawer.Title', () => {
|
|
30
|
+
it( 'renders an h2 and registers its id and text', () => {
|
|
31
|
+
const setTitle = jest.fn();
|
|
32
|
+
withContext( <Title>Edit Styles</Title>, { setTitle } );
|
|
33
|
+
const heading = screen.getByRole( 'heading', { level: 2, name: 'Edit Styles' } );
|
|
34
|
+
expect( heading.id ).toBeTruthy();
|
|
35
|
+
expect( setTitle ).toHaveBeenCalledWith( { id: heading.id, text: 'Edit Styles' } );
|
|
36
|
+
} );
|
|
37
|
+
|
|
38
|
+
it( 'registers null text for non-string children and clears on unmount', () => {
|
|
39
|
+
const setTitle = jest.fn();
|
|
40
|
+
const { unmount } = withContext(
|
|
41
|
+
<Title>
|
|
42
|
+
<em>Rich</em>
|
|
43
|
+
</Title>,
|
|
44
|
+
{ setTitle }
|
|
45
|
+
);
|
|
46
|
+
expect( setTitle ).toHaveBeenCalledWith( expect.objectContaining( { text: null } ) );
|
|
47
|
+
unmount();
|
|
48
|
+
expect( setTitle ).toHaveBeenLastCalledWith( null, expect.any( String ) );
|
|
49
|
+
} );
|
|
50
|
+
} );
|
|
51
|
+
|
|
52
|
+
describe( 'Drawer.CloseIcon', () => {
|
|
53
|
+
it( 'is named "Close" without a title and funnels clicks to requestClose', () => {
|
|
54
|
+
const requestClose = jest.fn();
|
|
55
|
+
withContext( <CloseIcon />, { requestClose } );
|
|
56
|
+
const button = screen.getByRole( 'button', { name: 'Close' } );
|
|
57
|
+
fireEvent.click( button );
|
|
58
|
+
expect( requestClose ).toHaveBeenCalled();
|
|
59
|
+
} );
|
|
60
|
+
|
|
61
|
+
it( 'composes the label with a string title', () => {
|
|
62
|
+
withContext( <CloseIcon />, { title: { id: 't', text: 'Edit Styles' } } );
|
|
63
|
+
expect( screen.getByRole( 'button', { name: 'Close Edit Styles' } ) ).toBeInTheDocument();
|
|
64
|
+
} );
|
|
65
|
+
|
|
66
|
+
it( 'accepts a label override', () => {
|
|
67
|
+
withContext( <CloseIcon label="Dismiss panel" />, { title: { id: 't', text: 'Edit Styles' } } );
|
|
68
|
+
expect( screen.getByRole( 'button', { name: 'Dismiss panel' } ) ).toBeInTheDocument();
|
|
69
|
+
} );
|
|
70
|
+
} );
|
|
71
|
+
|
|
72
|
+
describe( 'Drawer.Content', () => {
|
|
73
|
+
const sectionOf = container => container.querySelector( '.newspack-drawer__content' );
|
|
74
|
+
|
|
75
|
+
// jest-dom's toHaveStyle is unreliable for custom properties in jsdom.
|
|
76
|
+
it( 'defaults to padding 6 on the 4px scale', () => {
|
|
77
|
+
const { container } = render( <Content>body</Content> );
|
|
78
|
+
expect( sectionOf( container ).style.getPropertyValue( '--newspack-drawer-content-padding' ) ).toBe( '24px' );
|
|
79
|
+
} );
|
|
80
|
+
|
|
81
|
+
it( 'accepts padding 0 for a flush section', () => {
|
|
82
|
+
const { container } = render( <Content padding={ 0 }>flush</Content> );
|
|
83
|
+
expect( sectionOf( container ).style.getPropertyValue( '--newspack-drawer-content-padding' ) ).toBe( '0px' );
|
|
84
|
+
} );
|
|
85
|
+
|
|
86
|
+
it( 'renders each section as a VStack', () => {
|
|
87
|
+
const { container } = render( <Content>stacked</Content> );
|
|
88
|
+
expect( sectionOf( container ) ).toHaveClass( 'newspack-drawer__content', 'components-v-stack' );
|
|
89
|
+
} );
|
|
90
|
+
|
|
91
|
+
// VStack emits its spacing as a `calc()` on the 4px base, not as pixels.
|
|
92
|
+
it( 'spaces its children by 16px, and by the gap prop', () => {
|
|
93
|
+
const { container, rerender } = render( <Content>stacked</Content> );
|
|
94
|
+
expect( window.getComputedStyle( sectionOf( container ) ).gap ).toBe( 'calc(4px * 4)' );
|
|
95
|
+
|
|
96
|
+
rerender( <Content gap={ 2 }>stacked</Content> );
|
|
97
|
+
expect( window.getComputedStyle( sectionOf( container ) ).gap ).toBe( 'calc(4px * 2)' );
|
|
98
|
+
} );
|
|
99
|
+
|
|
100
|
+
// VStack keeps only elements, so these would otherwise vanish without warning.
|
|
101
|
+
it( 'keeps plain text sitting beside an element', () => {
|
|
102
|
+
render(
|
|
103
|
+
<Content>
|
|
104
|
+
Lead text
|
|
105
|
+
<span>an element</span>
|
|
106
|
+
{ 42 }
|
|
107
|
+
</Content>
|
|
108
|
+
);
|
|
109
|
+
expect( screen.getByText( 'Lead text' ) ).toBeInTheDocument();
|
|
110
|
+
expect( screen.getByText( 'an element' ) ).toBeInTheDocument();
|
|
111
|
+
expect( screen.getByText( '42' ) ).toBeInTheDocument();
|
|
112
|
+
} );
|
|
113
|
+
|
|
114
|
+
it( 'keeps a run of text and interpolations on one row', () => {
|
|
115
|
+
const { container } = render( <Content>Edited by { 'Ada' } just now</Content> );
|
|
116
|
+
const rows = sectionOf( container ).children;
|
|
117
|
+
expect( rows ).toHaveLength( 1 );
|
|
118
|
+
expect( rows[ 0 ] ).toHaveTextContent( 'Edited by Ada just now' );
|
|
119
|
+
} );
|
|
120
|
+
|
|
121
|
+
it( 'starts a new row either side of an element', () => {
|
|
122
|
+
const { container } = render(
|
|
123
|
+
<Content>
|
|
124
|
+
before
|
|
125
|
+
<span>middle</span>
|
|
126
|
+
after
|
|
127
|
+
</Content>
|
|
128
|
+
);
|
|
129
|
+
expect( Array.from( sectionOf( container ).children ).map( row => row.textContent ) ).toEqual( [ 'before', 'middle', 'after' ] );
|
|
130
|
+
} );
|
|
131
|
+
|
|
132
|
+
it( 'emits no row for whitespace or empty text between elements', () => {
|
|
133
|
+
const { container } = render(
|
|
134
|
+
<Content>
|
|
135
|
+
<span>one</span> { '' }
|
|
136
|
+
<span>two</span>
|
|
137
|
+
</Content>
|
|
138
|
+
);
|
|
139
|
+
expect( Array.from( sectionOf( container ).children ).map( row => row.textContent ) ).toEqual( [ 'one', 'two' ] );
|
|
140
|
+
} );
|
|
141
|
+
|
|
142
|
+
it( 'keeps a lone string', () => {
|
|
143
|
+
render( <Content>on its own</Content> );
|
|
144
|
+
expect( screen.getByText( 'on its own' ) ).toBeInTheDocument();
|
|
145
|
+
} );
|
|
146
|
+
} );
|
|
147
|
+
|
|
148
|
+
describe( 'Drawer.Divider', () => {
|
|
149
|
+
it( 'renders a separator, merging className', () => {
|
|
150
|
+
const { container } = render( <Divider className="custom" /> );
|
|
151
|
+
const divider = container.querySelector( 'hr' );
|
|
152
|
+
expect( divider ).toHaveClass( 'newspack-drawer__divider', 'custom' );
|
|
153
|
+
expect( screen.getByRole( 'separator' ) ).toBe( divider );
|
|
154
|
+
} );
|
|
155
|
+
} );
|
|
156
|
+
|
|
157
|
+
describe( 'Drawer.Footer', () => {
|
|
158
|
+
it( 'renders a container with its children', () => {
|
|
159
|
+
render( <Footer>actions</Footer> );
|
|
160
|
+
expect( screen.getByText( 'actions' ) ).toHaveClass( 'newspack-drawer__footer' );
|
|
161
|
+
} );
|
|
162
|
+
|
|
163
|
+
it( 'renders its actions as direct children', () => {
|
|
164
|
+
const { container } = render(
|
|
165
|
+
<Footer>
|
|
166
|
+
<Action>One</Action>
|
|
167
|
+
<Action>Two</Action>
|
|
168
|
+
<Action>Three</Action>
|
|
169
|
+
</Footer>
|
|
170
|
+
);
|
|
171
|
+
const footer = container.querySelector( '.newspack-drawer__footer' );
|
|
172
|
+
expect( Array.from( footer.children ) ).toEqual( [
|
|
173
|
+
screen.getByRole( 'button', { name: 'One' } ),
|
|
174
|
+
screen.getByRole( 'button', { name: 'Two' } ),
|
|
175
|
+
screen.getByRole( 'button', { name: 'Three' } ),
|
|
176
|
+
] );
|
|
177
|
+
} );
|
|
178
|
+
} );
|
|
179
|
+
|
|
180
|
+
describe( 'Drawer.Action', () => {
|
|
181
|
+
it( 'renders a button with aria-label from ariaLabel', () => {
|
|
182
|
+
render( <Action ariaLabel="Save styles">Save</Action> );
|
|
183
|
+
expect( screen.getByRole( 'button', { name: 'Save styles' } ) ).toBeInTheDocument();
|
|
184
|
+
} );
|
|
185
|
+
|
|
186
|
+
it( 'warns when ariaLabel does not contain the visible label', () => {
|
|
187
|
+
const warn = jest.spyOn( console, 'warn' ).mockImplementation( () => {} );
|
|
188
|
+
render( <Action ariaLabel="Apply changes">Save</Action> );
|
|
189
|
+
expect( warn ).toHaveBeenCalledWith( expect.stringContaining( 'Label in Name' ) );
|
|
190
|
+
warn.mockRestore();
|
|
191
|
+
} );
|
|
192
|
+
|
|
193
|
+
it( 'does not warn when ariaLabel contains the visible label', () => {
|
|
194
|
+
const warn = jest.spyOn( console, 'warn' ).mockImplementation( () => {} );
|
|
195
|
+
render( <Action ariaLabel="Save styles">Save</Action> );
|
|
196
|
+
expect( warn ).not.toHaveBeenCalled();
|
|
197
|
+
warn.mockRestore();
|
|
198
|
+
} );
|
|
199
|
+
|
|
200
|
+
it( 'hands the click event to onClick', () => {
|
|
201
|
+
const onClick = jest.fn();
|
|
202
|
+
render( <Action onClick={ onClick }>Save</Action> );
|
|
203
|
+
|
|
204
|
+
fireEvent.click( screen.getByRole( 'button', { name: 'Save' } ) );
|
|
205
|
+
expect( onClick ).toHaveBeenCalledWith( expect.objectContaining( { type: 'click' } ) );
|
|
206
|
+
} );
|
|
207
|
+
|
|
208
|
+
it( 'forwards the button props', () => {
|
|
209
|
+
render(
|
|
210
|
+
<Action variant="primary" isBusy isDestructive disabled>
|
|
211
|
+
Save
|
|
212
|
+
</Action>
|
|
213
|
+
);
|
|
214
|
+
const button = screen.getByRole( 'button', { name: 'Save' } );
|
|
215
|
+
expect( button ).toHaveClass( 'is-primary', 'is-busy', 'is-destructive' );
|
|
216
|
+
expect( button ).toBeDisabled();
|
|
217
|
+
} );
|
|
218
|
+
|
|
219
|
+
it( 'forwards onClick', () => {
|
|
220
|
+
const onClick = jest.fn();
|
|
221
|
+
render( <Action onClick={ onClick }>Save</Action> );
|
|
222
|
+
fireEvent.click( screen.getByRole( 'button', { name: 'Save' } ) );
|
|
223
|
+
expect( onClick ).toHaveBeenCalledTimes( 1 );
|
|
224
|
+
} );
|
|
225
|
+
|
|
226
|
+
// Button drops the href as soon as an onClick is present.
|
|
227
|
+
it( 'renders an anchor for an href-only action', () => {
|
|
228
|
+
render( <Action href="/somewhere">Go</Action> );
|
|
229
|
+
const link = screen.getByRole( 'link', { name: 'Go' } );
|
|
230
|
+
expect( link ).toHaveAttribute( 'href', '/somewhere' );
|
|
231
|
+
} );
|
|
232
|
+
|
|
233
|
+
// The types forbid this, but the package is consumed from plain JS.
|
|
234
|
+
it( 'stays a link and warns when href is combined with closes', () => {
|
|
235
|
+
const warn = jest.spyOn( console, 'warn' ).mockImplementation( () => {} );
|
|
236
|
+
const requestClose = jest.fn();
|
|
237
|
+
const onClick = jest.fn();
|
|
238
|
+
withContext(
|
|
239
|
+
<Action href="/somewhere" closes onClick={ onClick }>
|
|
240
|
+
Go
|
|
241
|
+
</Action>,
|
|
242
|
+
{ requestClose }
|
|
243
|
+
);
|
|
244
|
+
|
|
245
|
+
const link = screen.getByRole( 'link', { name: 'Go' } );
|
|
246
|
+
expect( link ).toHaveAttribute( 'href', '/somewhere' );
|
|
247
|
+
expect( warn ).toHaveBeenCalledWith( expect.stringContaining( 'cannot also take' ) );
|
|
248
|
+
|
|
249
|
+
fireEvent.click( link );
|
|
250
|
+
expect( onClick ).not.toHaveBeenCalled();
|
|
251
|
+
expect( requestClose ).not.toHaveBeenCalled();
|
|
252
|
+
warn.mockRestore();
|
|
253
|
+
} );
|
|
254
|
+
|
|
255
|
+
// An empty string still renders an anchor, so truthiness would re-arm the funnel.
|
|
256
|
+
it( 'treats an empty href as a link rather than re-arming the close funnel', () => {
|
|
257
|
+
const warn = jest.spyOn( console, 'warn' ).mockImplementation( () => {} );
|
|
258
|
+
const requestClose = jest.fn();
|
|
259
|
+
const onClick = jest.fn();
|
|
260
|
+
withContext(
|
|
261
|
+
<Action href="" closes onClick={ onClick }>
|
|
262
|
+
Go
|
|
263
|
+
</Action>,
|
|
264
|
+
{ requestClose }
|
|
265
|
+
);
|
|
266
|
+
|
|
267
|
+
const anchor = screen.getByText( 'Go' ).closest( 'a' );
|
|
268
|
+
expect( anchor ).toHaveAttribute( 'href', '' );
|
|
269
|
+
expect( warn ).toHaveBeenCalledWith( expect.stringContaining( 'cannot also take' ) );
|
|
270
|
+
|
|
271
|
+
fireEvent.click( anchor );
|
|
272
|
+
expect( onClick ).not.toHaveBeenCalled();
|
|
273
|
+
expect( requestClose ).not.toHaveBeenCalled();
|
|
274
|
+
warn.mockRestore();
|
|
275
|
+
} );
|
|
276
|
+
|
|
277
|
+
// No link, so the handlers must stay attached rather than leaving a dead anchor.
|
|
278
|
+
it( 'runs the handlers when a nullable href resolves to nothing', () => {
|
|
279
|
+
const requestClose = jest.fn();
|
|
280
|
+
const onClick = jest.fn();
|
|
281
|
+
withContext(
|
|
282
|
+
<Action href={ null } closes onClick={ onClick }>
|
|
283
|
+
Go
|
|
284
|
+
</Action>,
|
|
285
|
+
{ requestClose }
|
|
286
|
+
);
|
|
287
|
+
|
|
288
|
+
const button = screen.getByRole( 'button', { name: 'Go' } );
|
|
289
|
+
fireEvent.click( button );
|
|
290
|
+
expect( onClick ).toHaveBeenCalledTimes( 1 );
|
|
291
|
+
expect( requestClose ).toHaveBeenCalledTimes( 1 );
|
|
292
|
+
} );
|
|
293
|
+
|
|
294
|
+
it( 'requests the drawer close after onClick when `closes` is set', () => {
|
|
295
|
+
const requestClose = jest.fn();
|
|
296
|
+
const onClick = jest.fn( () => expect( requestClose ).not.toHaveBeenCalled() );
|
|
297
|
+
withContext(
|
|
298
|
+
<Action closes onClick={ onClick }>
|
|
299
|
+
Cancel
|
|
300
|
+
</Action>,
|
|
301
|
+
{ requestClose }
|
|
302
|
+
);
|
|
303
|
+
fireEvent.click( screen.getByRole( 'button', { name: 'Cancel' } ) );
|
|
304
|
+
expect( onClick ).toHaveBeenCalledTimes( 1 );
|
|
305
|
+
expect( requestClose ).toHaveBeenCalledTimes( 1 );
|
|
306
|
+
} );
|
|
307
|
+
|
|
308
|
+
it( 'does not request the close without `closes`', () => {
|
|
309
|
+
const requestClose = jest.fn();
|
|
310
|
+
withContext( <Action>Save</Action>, { requestClose } );
|
|
311
|
+
fireEvent.click( screen.getByRole( 'button', { name: 'Save' } ) );
|
|
312
|
+
expect( requestClose ).not.toHaveBeenCalled();
|
|
313
|
+
} );
|
|
314
|
+
} );
|