newspack-components 4.10.0-alpha.1 → 4.11.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.
- package/CHANGELOG.md +10 -3
- package/DEVELOPMENT.md +1 -0
- package/dist/cjs/card-sortable-list/index.js +6 -3
- package/dist/cjs/card-sortable-list/index.test.js +21 -0
- package/dist/cjs/card-sortable-list/style.scss +8 -0
- package/dist/cjs/index.js +7 -0
- package/dist/cjs/reorder-modal/index.js +521 -0
- package/dist/cjs/reorder-modal/index.test.js +1223 -0
- package/dist/cjs/reorder-modal/style.scss +114 -0
- package/dist/esm/card-sortable-list/index.js +5 -2
- package/dist/esm/card-sortable-list/index.test.js +21 -0
- package/dist/esm/card-sortable-list/style.scss +8 -0
- package/dist/esm/index.js +1 -0
- package/dist/esm/reorder-modal/index.js +514 -0
- package/dist/esm/reorder-modal/index.test.js +1224 -0
- package/dist/esm/reorder-modal/style.scss +114 -0
- package/package.json +3 -3
- package/src/card-sortable-list/index.test.js +13 -0
- package/src/card-sortable-list/index.tsx +4 -1
- package/src/card-sortable-list/style.scss +8 -0
- package/src/index.js +1 -0
- package/src/reorder-modal/index.js +484 -0
- package/src/reorder-modal/index.test.js +566 -0
- package/src/reorder-modal/style.scss +114 -0
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
@use "~@wordpress/base-styles/colors" as wp-colors;
|
|
2
|
+
@use "~@wordpress/base-styles/mixins" as wp-mixins;
|
|
3
|
+
@use "~@wordpress/base-styles/variables" as wp-vars;
|
|
4
|
+
|
|
5
|
+
.newspack-reorder-modal {
|
|
6
|
+
&__body {
|
|
7
|
+
display: flex;
|
|
8
|
+
flex-direction: column;
|
|
9
|
+
min-height: calc(#{wp-vars.$grid-unit} * 23);
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
&__loading {
|
|
13
|
+
align-items: center;
|
|
14
|
+
display: flex;
|
|
15
|
+
flex: 1;
|
|
16
|
+
justify-content: center;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
&__notice {
|
|
20
|
+
margin: 0 0 wp-vars.$grid-unit-10;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
&__list {
|
|
24
|
+
display: flex;
|
|
25
|
+
flex-direction: column;
|
|
26
|
+
gap: wp-vars.$grid-unit-10;
|
|
27
|
+
list-style: none;
|
|
28
|
+
margin: 0;
|
|
29
|
+
padding: 0;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
&__item {
|
|
33
|
+
--newspack-reorder-modal-divider: #{wp-colors.$gray-100};
|
|
34
|
+
--wp-ui-card-padding: #{wp-vars.$grid-unit-15};
|
|
35
|
+
|
|
36
|
+
align-items: stretch;
|
|
37
|
+
cursor: grab;
|
|
38
|
+
flex-direction: row;
|
|
39
|
+
margin: 0;
|
|
40
|
+
|
|
41
|
+
&:focus-within,
|
|
42
|
+
&:hover {
|
|
43
|
+
--newspack-reorder-modal-divider: #{wp-colors.$gray-300};
|
|
44
|
+
|
|
45
|
+
border-color: wp-colors.$gray-300;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
&.is-dragging {
|
|
49
|
+
cursor: grabbing;
|
|
50
|
+
opacity: 0.5;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
&__grip {
|
|
55
|
+
align-items: center;
|
|
56
|
+
color: wp-colors.$gray-700;
|
|
57
|
+
display: flex;
|
|
58
|
+
padding-left: wp-vars.$grid-unit-05;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
&__move {
|
|
62
|
+
align-items: center;
|
|
63
|
+
border-right: 1px solid var(--newspack-reorder-modal-divider);
|
|
64
|
+
display: flex;
|
|
65
|
+
flex-direction: column;
|
|
66
|
+
padding: wp-vars.$grid-unit-05;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
&__content {
|
|
70
|
+
align-items: center;
|
|
71
|
+
align-self: center;
|
|
72
|
+
display: flex;
|
|
73
|
+
flex: 1;
|
|
74
|
+
gap: wp-vars.$grid-unit-10;
|
|
75
|
+
min-width: 0;
|
|
76
|
+
padding-block-end: var(--wp-ui-card-padding);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
&__title {
|
|
80
|
+
@include wp-mixins.heading-large();
|
|
81
|
+
|
|
82
|
+
min-width: 0;
|
|
83
|
+
overflow: hidden;
|
|
84
|
+
text-overflow: ellipsis;
|
|
85
|
+
white-space: nowrap;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
&__badge {
|
|
89
|
+
display: flex;
|
|
90
|
+
flex-shrink: 0;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
&__toggle {
|
|
94
|
+
align-items: center;
|
|
95
|
+
display: flex;
|
|
96
|
+
padding-inline-end: wp-vars.$grid-unit-15;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
&__description {
|
|
100
|
+
margin: 0 0 wp-vars.$grid-unit-20;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
&__item.is-off &__title {
|
|
104
|
+
color: wp-colors.$gray-700;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
&__footer {
|
|
108
|
+
display: flex;
|
|
109
|
+
gap: wp-vars.$grid-unit-10;
|
|
110
|
+
justify-content: flex-end;
|
|
111
|
+
margin-top: auto;
|
|
112
|
+
padding-top: wp-vars.$grid-unit-30;
|
|
113
|
+
}
|
|
114
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "newspack-components",
|
|
3
|
-
"version": "4.
|
|
3
|
+
"version": "4.11.0",
|
|
4
4
|
"description": "Newspack design system components",
|
|
5
5
|
"author": "Automattic",
|
|
6
6
|
"license": "GPL-2.0-or-later",
|
|
@@ -30,7 +30,7 @@
|
|
|
30
30
|
"classnames": "^2.3.1",
|
|
31
31
|
"lodash": "^4.17.21",
|
|
32
32
|
"moment-range": "^3.1.1",
|
|
33
|
-
"newspack-icons": "1.2.0
|
|
33
|
+
"newspack-icons": "1.2.0",
|
|
34
34
|
"qs": "^6.15.2",
|
|
35
35
|
"react-router-dom": "^5.3.4",
|
|
36
36
|
"@babel/runtime": "^7.29.7",
|
|
@@ -58,7 +58,7 @@
|
|
|
58
58
|
"@babel/preset-env": "^7.29.7",
|
|
59
59
|
"@babel/preset-react": "^7.29.7",
|
|
60
60
|
"@babel/preset-typescript": "^7.29.7",
|
|
61
|
-
"newspack-scripts": "5.12.0
|
|
61
|
+
"newspack-scripts": "5.12.0",
|
|
62
62
|
"recursive-copy": "^2.0.0"
|
|
63
63
|
},
|
|
64
64
|
"babel": {
|
|
@@ -26,4 +26,17 @@ describe( 'CardSortableList', () => {
|
|
|
26
26
|
// An unguarded Badge would still emit an empty span, so assert on element children.
|
|
27
27
|
expect( container.querySelector( 'h3' ).children ).toHaveLength( 0 );
|
|
28
28
|
} );
|
|
29
|
+
|
|
30
|
+
it( 'names each toggle after its item', () => {
|
|
31
|
+
render(
|
|
32
|
+
<CardSortableList
|
|
33
|
+
items={ [
|
|
34
|
+
{ id: 1, title: 'Homepage', toggleChecked: true, onToggleChange: () => {} },
|
|
35
|
+
{ id: 2, title: 'About', toggleChecked: false, onToggleChange: () => {} },
|
|
36
|
+
] }
|
|
37
|
+
/>
|
|
38
|
+
);
|
|
39
|
+
expect( screen.getByRole( 'checkbox', { name: 'Homepage' } ) ).toBeChecked();
|
|
40
|
+
expect( screen.getByRole( 'checkbox', { name: 'About' } ) ).not.toBeChecked();
|
|
41
|
+
} );
|
|
29
42
|
} );
|
|
@@ -12,7 +12,7 @@ import { Badge } from '@wordpress/ui';
|
|
|
12
12
|
/**
|
|
13
13
|
* Internal dependencies
|
|
14
14
|
*/
|
|
15
|
-
import
|
|
15
|
+
import Card from '../card';
|
|
16
16
|
import type { CardBadge } from '../types';
|
|
17
17
|
import './style.scss';
|
|
18
18
|
|
|
@@ -419,6 +419,9 @@ const CardSortableList = ( {
|
|
|
419
419
|
actionType: 'toggle',
|
|
420
420
|
isActive: item.toggleChecked,
|
|
421
421
|
onToggle: item.onToggleChange,
|
|
422
|
+
// Names the toggle for assistive tech. The card hides this
|
|
423
|
+
// label, since the header already shows the title.
|
|
424
|
+
title: item.title,
|
|
422
425
|
} ),
|
|
423
426
|
...( item.actions !== undefined && {
|
|
424
427
|
actions: item.actions,
|
|
@@ -6,6 +6,14 @@ $drop-duration: 400ms; /* must match DROP_ANIMATION_DURATION in index.tsx */
|
|
|
6
6
|
&__item {
|
|
7
7
|
cursor: grab;
|
|
8
8
|
|
|
9
|
+
/*
|
|
10
|
+
* Keep a card's title in the case it was written in. The block editor
|
|
11
|
+
* sidebar uppercases every h3 (`.interface-complementary-area h3`).
|
|
12
|
+
*/
|
|
13
|
+
.newspack-card--core__header-content h3 {
|
|
14
|
+
text-transform: none;
|
|
15
|
+
}
|
|
16
|
+
|
|
9
17
|
/*
|
|
10
18
|
* Animate items sliding to fill/make space around the dragged item.
|
|
11
19
|
*/
|
package/src/index.js
CHANGED
|
@@ -39,6 +39,7 @@ export { default as PluginToggle } from './plugin-toggle';
|
|
|
39
39
|
export { default as Popover } from './popover';
|
|
40
40
|
export { default as ProgressBar } from './progress-bar';
|
|
41
41
|
export { default as RadioControl } from './radio-control';
|
|
42
|
+
export { default as ReorderModal } from './reorder-modal';
|
|
42
43
|
export { default as SectionHeader } from './section-header';
|
|
43
44
|
export { default as SelectControl } from './select-control';
|
|
44
45
|
export { default as Settings } from './settings';
|
|
@@ -0,0 +1,484 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* External dependencies
|
|
3
|
+
*/
|
|
4
|
+
import classnames from 'classnames';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* WordPress dependencies
|
|
8
|
+
*/
|
|
9
|
+
import { __, _n, sprintf } from '@wordpress/i18n';
|
|
10
|
+
import { speak } from '@wordpress/a11y';
|
|
11
|
+
import { useCallback, useEffect, useRef, useState } from '@wordpress/element';
|
|
12
|
+
import { useInstanceId } from '@wordpress/compose';
|
|
13
|
+
import {
|
|
14
|
+
Button,
|
|
15
|
+
FormToggle,
|
|
16
|
+
Modal,
|
|
17
|
+
Notice,
|
|
18
|
+
Spinner,
|
|
19
|
+
Tooltip,
|
|
20
|
+
VisuallyHidden,
|
|
21
|
+
__experimentalConfirmDialog as ConfirmDialog, // eslint-disable-line @wordpress/no-unsafe-wp-apis
|
|
22
|
+
} from '@wordpress/components';
|
|
23
|
+
import { Icon, chevronDown, chevronUp, close, dragHandle } from '@wordpress/icons';
|
|
24
|
+
import { Badge, Card } from '@wordpress/ui';
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Internal dependencies
|
|
28
|
+
*/
|
|
29
|
+
import './style.scss';
|
|
30
|
+
|
|
31
|
+
export const moveItem = ( items, from, to ) => {
|
|
32
|
+
if ( from === to || from < 0 || to < 0 || from >= items.length || to >= items.length ) {
|
|
33
|
+
return items;
|
|
34
|
+
}
|
|
35
|
+
const next = [ ...items ];
|
|
36
|
+
const [ moved ] = next.splice( from, 1 );
|
|
37
|
+
next.splice( to, 0, moved );
|
|
38
|
+
return next;
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
const OVERLAY_SELECTOR = '.components-modal__screen-overlay';
|
|
42
|
+
|
|
43
|
+
// The overlay carries the dismissal handling and the drop target, and `Modal`
|
|
44
|
+
// forwards its ref to it today. Deriving it from whatever node arrives keeps
|
|
45
|
+
// both working if core ever forwards that ref somewhere else.
|
|
46
|
+
export const findOverlay = node =>
|
|
47
|
+
node?.closest?.( OVERLAY_SELECTOR ) ||
|
|
48
|
+
node?.querySelector?.( OVERLAY_SELECTOR ) ||
|
|
49
|
+
node?.ownerDocument?.querySelector( OVERLAY_SELECTOR ) ||
|
|
50
|
+
null;
|
|
51
|
+
|
|
52
|
+
// Disabled controls stay focusable here, so they report `aria-disabled` rather
|
|
53
|
+
// than the native attribute.
|
|
54
|
+
const isAvailable = button => !! button && ! button.matches( ':disabled, [aria-disabled="true"]' );
|
|
55
|
+
|
|
56
|
+
// Speech input matches on the visible label, so it has to open the accessible
|
|
57
|
+
// name. Composing both from one translated string keeps that true in every locale.
|
|
58
|
+
const nameWithTitle = ( label, title ) =>
|
|
59
|
+
sprintf(
|
|
60
|
+
/* translators: keep %1$s first: it repeats the button's visible label, which speech input matches on. 1: the control's visible label. 2: title of the content being moved. */
|
|
61
|
+
__( '%1$s: %2$s', 'newspack-plugin' ),
|
|
62
|
+
label,
|
|
63
|
+
title
|
|
64
|
+
);
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Put a short list of items in order and, optionally, choose which are on.
|
|
68
|
+
* Changes apply on Save.
|
|
69
|
+
*
|
|
70
|
+
* @param {Object} props Component props.
|
|
71
|
+
* @param {string} props.title Modal title.
|
|
72
|
+
* @param {Array} props.ids Item IDs, in their current order.
|
|
73
|
+
* @param {Function} props.fetchItems Resolves the IDs to `{ value, label, badge? }`, where `badge` is `{ label, tooltip? }`.
|
|
74
|
+
* @param {Function} props.onSave Called with the new order and, when `enabledIds` is given, the enabled IDs: those
|
|
75
|
+
* on, in that order, then any from `enabledIds` the modal had no row for, kept as they were.
|
|
76
|
+
* @param {Function} props.onClose Called when the modal closes without saving.
|
|
77
|
+
* @param {Array} props.enabledIds Optional. Gives each row an on/off toggle, starting on for these IDs. Save is held while none is on.
|
|
78
|
+
* @param {string} props.description Optional. Introduces the list, above it.
|
|
79
|
+
* @param {string} props.noneOnHelp Why Save is held while no toggle is on.
|
|
80
|
+
*/
|
|
81
|
+
const ReorderModal = ( {
|
|
82
|
+
title,
|
|
83
|
+
ids,
|
|
84
|
+
fetchItems,
|
|
85
|
+
onSave,
|
|
86
|
+
onClose,
|
|
87
|
+
enabledIds,
|
|
88
|
+
description,
|
|
89
|
+
noneOnHelp = __( 'Turn on at least one item to save.', 'newspack-plugin' ),
|
|
90
|
+
} ) => {
|
|
91
|
+
const hasToggles = Array.isArray( enabledIds );
|
|
92
|
+
const instanceId = useInstanceId( ReorderModal, 'newspack-reorder-modal' );
|
|
93
|
+
const moveUpLabel = __( 'Move Up', 'newspack-plugin' );
|
|
94
|
+
const moveDownLabel = __( 'Move Down', 'newspack-plugin' );
|
|
95
|
+
const [ items, setItems ] = useState( null );
|
|
96
|
+
const [ dragIndex, setDragIndex ] = useState( null );
|
|
97
|
+
const [ hasFetchError, setHasFetchError ] = useState( false );
|
|
98
|
+
const [ isConfirmingDiscard, setIsConfirmingDiscard ] = useState( false );
|
|
99
|
+
const listRef = useRef( null );
|
|
100
|
+
const overlayRef = useRef( null );
|
|
101
|
+
const pendingFocus = useRef( null );
|
|
102
|
+
const closeRef = useRef( null );
|
|
103
|
+
const preDragItems = useRef( null );
|
|
104
|
+
const dragBlocked = useRef( false );
|
|
105
|
+
const captureOverlay = useCallback( node => {
|
|
106
|
+
overlayRef.current = findOverlay( node );
|
|
107
|
+
}, [] );
|
|
108
|
+
|
|
109
|
+
// The order the modal opened with. Snapshotted so a change to `ids` from
|
|
110
|
+
// elsewhere in the editor cannot desynchronise it from `items`.
|
|
111
|
+
const openedWith = useRef( ids );
|
|
112
|
+
const openedEnabled = useRef( hasToggles ? enabledIds : [] );
|
|
113
|
+
const [ enabled, setEnabled ] = useState( () => new Set( openedEnabled.current ) );
|
|
114
|
+
|
|
115
|
+
const isReordered = !! items && items.some( ( item, index ) => item.id !== openedWith.current[ index ] );
|
|
116
|
+
const isToggled = enabled.size !== openedEnabled.current.length || openedEnabled.current.some( id => ! enabled.has( id ) );
|
|
117
|
+
const isDirty = isReordered || ( hasToggles && isToggled );
|
|
118
|
+
// Enabled IDs without a row can't be switched off here, so Save keeps them.
|
|
119
|
+
const enabledWithoutRow = openedEnabled.current.filter( id => ! openedWith.current.includes( id ) );
|
|
120
|
+
const isNoneOn = hasToggles && 0 === enabled.size && 0 === enabledWithoutRow.length;
|
|
121
|
+
|
|
122
|
+
const toggleItem = id =>
|
|
123
|
+
setEnabled( current => {
|
|
124
|
+
const next = new Set( current );
|
|
125
|
+
if ( next.has( id ) ) {
|
|
126
|
+
next.delete( id );
|
|
127
|
+
} else {
|
|
128
|
+
next.add( id );
|
|
129
|
+
}
|
|
130
|
+
return next;
|
|
131
|
+
} );
|
|
132
|
+
|
|
133
|
+
useEffect( () => {
|
|
134
|
+
let cancelled = false;
|
|
135
|
+
const openIds = openedWith.current;
|
|
136
|
+
const withLabels = ( labels, badges ) =>
|
|
137
|
+
openIds.map( id => ( { id, label: labels[ id ] || __( '(no title)', 'newspack-plugin' ), badge: badges[ id ] } ) );
|
|
138
|
+
|
|
139
|
+
fetchItems( openIds )
|
|
140
|
+
.then( results => {
|
|
141
|
+
if ( cancelled ) {
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
const labels = {};
|
|
145
|
+
const badges = {};
|
|
146
|
+
results.forEach( ( { value, label, badge } ) => {
|
|
147
|
+
labels[ value ] = label;
|
|
148
|
+
badges[ value ] = badge;
|
|
149
|
+
} );
|
|
150
|
+
const loaded = withLabels( labels, badges );
|
|
151
|
+
setItems( loaded );
|
|
152
|
+
speak(
|
|
153
|
+
sprintf(
|
|
154
|
+
/* translators: %d: number of items in the list. */
|
|
155
|
+
_n( '%d item ready to reorder.', '%d items ready to reorder.', loaded.length, 'newspack-plugin' ),
|
|
156
|
+
loaded.length
|
|
157
|
+
)
|
|
158
|
+
);
|
|
159
|
+
} )
|
|
160
|
+
.catch( error => {
|
|
161
|
+
// eslint-disable-next-line no-console
|
|
162
|
+
console.error( 'Newspack: could not prepare the items to reorder.', error );
|
|
163
|
+
if ( ! cancelled ) {
|
|
164
|
+
setHasFetchError( true );
|
|
165
|
+
// Only to leave the loading state: the error view lists nothing.
|
|
166
|
+
setItems( [] );
|
|
167
|
+
}
|
|
168
|
+
} );
|
|
169
|
+
|
|
170
|
+
return () => {
|
|
171
|
+
cancelled = true;
|
|
172
|
+
};
|
|
173
|
+
}, [] ); // eslint-disable-line react-hooks/exhaustive-deps
|
|
174
|
+
|
|
175
|
+
// A chevron that reaches an end of the list stops accepting moves, so send
|
|
176
|
+
// focus to the row's other chevron.
|
|
177
|
+
useEffect( () => {
|
|
178
|
+
const pending = pendingFocus.current;
|
|
179
|
+
pendingFocus.current = null;
|
|
180
|
+
if ( ! pending || ! listRef.current ) {
|
|
181
|
+
return;
|
|
182
|
+
}
|
|
183
|
+
const row = listRef.current.querySelector( `[data-item-id="${ pending.id }"]` );
|
|
184
|
+
if ( ! row ) {
|
|
185
|
+
return;
|
|
186
|
+
}
|
|
187
|
+
const preferred = row.querySelector( `[data-direction="${ pending.direction }"]` );
|
|
188
|
+
const fallback = row.querySelector( `[data-direction="${ 'up' === pending.direction ? 'down' : 'up' }"]` );
|
|
189
|
+
if ( isAvailable( preferred ) ) {
|
|
190
|
+
preferred.focus();
|
|
191
|
+
} else if ( isAvailable( fallback ) ) {
|
|
192
|
+
fallback.focus();
|
|
193
|
+
}
|
|
194
|
+
}, [ items ] );
|
|
195
|
+
|
|
196
|
+
const moveTo = ( from, to, direction ) => {
|
|
197
|
+
const next = moveItem( items, from, to );
|
|
198
|
+
if ( next === items ) {
|
|
199
|
+
return;
|
|
200
|
+
}
|
|
201
|
+
pendingFocus.current = { id: items[ from ].id, direction };
|
|
202
|
+
setItems( next );
|
|
203
|
+
speak(
|
|
204
|
+
sprintf(
|
|
205
|
+
/* translators: 1: title of the moved item. 2: its new position. 3: total number of items. */
|
|
206
|
+
__( '%1$s moved to position %2$d of %3$d.', 'newspack-plugin' ),
|
|
207
|
+
items[ from ].label,
|
|
208
|
+
to + 1,
|
|
209
|
+
items.length
|
|
210
|
+
)
|
|
211
|
+
);
|
|
212
|
+
};
|
|
213
|
+
|
|
214
|
+
const requestClose = () => {
|
|
215
|
+
if ( isDirty ) {
|
|
216
|
+
setIsConfirmingDiscard( true );
|
|
217
|
+
return;
|
|
218
|
+
}
|
|
219
|
+
onClose();
|
|
220
|
+
};
|
|
221
|
+
|
|
222
|
+
useEffect( () => {
|
|
223
|
+
closeRef.current = requestClose;
|
|
224
|
+
}, [ requestClose ] );
|
|
225
|
+
|
|
226
|
+
const handleKeyDown = event => {
|
|
227
|
+
if ( 'Escape' !== event.key || event.defaultPrevented ) {
|
|
228
|
+
return;
|
|
229
|
+
}
|
|
230
|
+
event.preventDefault();
|
|
231
|
+
requestClose();
|
|
232
|
+
};
|
|
233
|
+
|
|
234
|
+
// `Modal` plays its exit animation before it reports a close request, which
|
|
235
|
+
// would fade the modal out and back in whenever the confirmation vetoes one.
|
|
236
|
+
// Its own dismissal handlers are off, so drive the overlay press here.
|
|
237
|
+
useEffect( () => {
|
|
238
|
+
const overlay = overlayRef.current;
|
|
239
|
+
if ( ! overlay ) {
|
|
240
|
+
return;
|
|
241
|
+
}
|
|
242
|
+
let pressedOverlay = false;
|
|
243
|
+
const onPointerDown = event => {
|
|
244
|
+
pressedOverlay = event.target === overlay;
|
|
245
|
+
if ( pressedOverlay ) {
|
|
246
|
+
event.preventDefault();
|
|
247
|
+
}
|
|
248
|
+
};
|
|
249
|
+
const onPointerUp = event => {
|
|
250
|
+
const dismisses = pressedOverlay && 0 === event.button && event.target === overlay;
|
|
251
|
+
pressedOverlay = false;
|
|
252
|
+
if ( dismisses ) {
|
|
253
|
+
closeRef.current();
|
|
254
|
+
}
|
|
255
|
+
};
|
|
256
|
+
overlay.addEventListener( 'pointerdown', onPointerDown );
|
|
257
|
+
overlay.addEventListener( 'pointerup', onPointerUp );
|
|
258
|
+
return () => {
|
|
259
|
+
overlay.removeEventListener( 'pointerdown', onPointerDown );
|
|
260
|
+
overlay.removeEventListener( 'pointerup', onPointerUp );
|
|
261
|
+
};
|
|
262
|
+
}, [] );
|
|
263
|
+
|
|
264
|
+
// Rows are the only drop targets, and the frame's own padding is not one, so a
|
|
265
|
+
// release a few pixels wide of the list would report a cancelled drag and undo
|
|
266
|
+
// the reorder. Accepting the drop across the whole dialog leaves "cancelled"
|
|
267
|
+
// meaning only Escape or a release outside it.
|
|
268
|
+
useEffect( () => {
|
|
269
|
+
const frame = overlayRef.current?.querySelector( '.components-modal__frame' );
|
|
270
|
+
if ( ! frame ) {
|
|
271
|
+
return;
|
|
272
|
+
}
|
|
273
|
+
const accept = event => event.preventDefault();
|
|
274
|
+
frame.addEventListener( 'dragover', accept );
|
|
275
|
+
frame.addEventListener( 'drop', accept );
|
|
276
|
+
return () => {
|
|
277
|
+
frame.removeEventListener( 'dragover', accept );
|
|
278
|
+
frame.removeEventListener( 'drop', accept );
|
|
279
|
+
};
|
|
280
|
+
}, [] );
|
|
281
|
+
|
|
282
|
+
const handleDragOver = ( event, index ) => {
|
|
283
|
+
event.preventDefault();
|
|
284
|
+
if ( null === dragIndex ) {
|
|
285
|
+
return;
|
|
286
|
+
}
|
|
287
|
+
event.dataTransfer.dropEffect = 'move';
|
|
288
|
+
if ( dragIndex === index ) {
|
|
289
|
+
return;
|
|
290
|
+
}
|
|
291
|
+
setItems( moveItem( items, dragIndex, index ) );
|
|
292
|
+
setDragIndex( index );
|
|
293
|
+
};
|
|
294
|
+
|
|
295
|
+
return (
|
|
296
|
+
<>
|
|
297
|
+
<Modal
|
|
298
|
+
ref={ captureOverlay }
|
|
299
|
+
title={ title }
|
|
300
|
+
onRequestClose={ requestClose }
|
|
301
|
+
onKeyDown={ handleKeyDown }
|
|
302
|
+
isDismissible={ false }
|
|
303
|
+
shouldCloseOnEsc={ false }
|
|
304
|
+
shouldCloseOnClickOutside={ false }
|
|
305
|
+
headerActions={ <Button size="compact" icon={ close } label={ __( 'Close', 'newspack-plugin' ) } onClick={ requestClose } /> }
|
|
306
|
+
size="medium"
|
|
307
|
+
className="newspack-reorder-modal"
|
|
308
|
+
aria={ { describedby: description ? `${ instanceId }-description` : undefined } }
|
|
309
|
+
>
|
|
310
|
+
<div className="newspack-reorder-modal__body" aria-busy={ ! items }>
|
|
311
|
+
{ description && (
|
|
312
|
+
<p id={ `${ instanceId }-description` } className="newspack-reorder-modal__description">
|
|
313
|
+
{ description }
|
|
314
|
+
</p>
|
|
315
|
+
) }
|
|
316
|
+
{ ! items && (
|
|
317
|
+
<div className="newspack-reorder-modal__loading">
|
|
318
|
+
<Spinner />
|
|
319
|
+
<span className="screen-reader-text">{ __( 'Loading content…', 'newspack-plugin' ) }</span>
|
|
320
|
+
</div>
|
|
321
|
+
) }
|
|
322
|
+
{ items && hasFetchError && (
|
|
323
|
+
<>
|
|
324
|
+
<Notice status="error" isDismissible={ false } className="newspack-reorder-modal__notice">
|
|
325
|
+
{ __( 'The content could not be loaded, so the order cannot be saved.', 'newspack-plugin' ) }
|
|
326
|
+
</Notice>
|
|
327
|
+
<div className="newspack-reorder-modal__footer">
|
|
328
|
+
<Button variant="tertiary" onClick={ requestClose }>
|
|
329
|
+
{ __( 'Cancel', 'newspack-plugin' ) }
|
|
330
|
+
</Button>
|
|
331
|
+
</div>
|
|
332
|
+
</>
|
|
333
|
+
) }
|
|
334
|
+
{ items && ! hasFetchError && (
|
|
335
|
+
<>
|
|
336
|
+
{ /* `list-style: none` drops list semantics in Safari, so the role is restated. */ }
|
|
337
|
+
{ /* eslint-disable-next-line jsx-a11y/no-redundant-roles */ }
|
|
338
|
+
<ul className="newspack-reorder-modal__list" role="list" ref={ listRef }>
|
|
339
|
+
{ items.map( ( item, index ) => (
|
|
340
|
+
<Card.Root
|
|
341
|
+
key={ item.id }
|
|
342
|
+
render={ <li /> }
|
|
343
|
+
data-item-id={ item.id }
|
|
344
|
+
className={ classnames( 'newspack-reorder-modal__item', {
|
|
345
|
+
'is-dragging': dragIndex === index,
|
|
346
|
+
'is-off': hasToggles && ! enabled.has( item.id ),
|
|
347
|
+
} ) }
|
|
348
|
+
draggable
|
|
349
|
+
// `dragstart` always fires at the draggable row, never at the
|
|
350
|
+
// chevron inside it, so the grab point is recorded separately.
|
|
351
|
+
onPointerDown={ event => {
|
|
352
|
+
dragBlocked.current = !! event.target.closest?.( 'button, input' );
|
|
353
|
+
} }
|
|
354
|
+
onDragStart={ event => {
|
|
355
|
+
if ( dragBlocked.current ) {
|
|
356
|
+
event.preventDefault();
|
|
357
|
+
return;
|
|
358
|
+
}
|
|
359
|
+
preDragItems.current = items;
|
|
360
|
+
// Firefox and Safari will not start a drag until the payload is set.
|
|
361
|
+
event.dataTransfer.setData( 'text/plain', String( item.id ) );
|
|
362
|
+
event.dataTransfer.effectAllowed = 'move';
|
|
363
|
+
setDragIndex( index );
|
|
364
|
+
} }
|
|
365
|
+
onDragOver={ event => handleDragOver( event, index ) }
|
|
366
|
+
onDragEnd={ event => {
|
|
367
|
+
// A cancelled drag reports no drop effect: put the order back.
|
|
368
|
+
if ( 'none' === event.dataTransfer.dropEffect && preDragItems.current ) {
|
|
369
|
+
setItems( preDragItems.current );
|
|
370
|
+
}
|
|
371
|
+
preDragItems.current = null;
|
|
372
|
+
setDragIndex( null );
|
|
373
|
+
} }
|
|
374
|
+
onDrop={ event => event.preventDefault() }
|
|
375
|
+
>
|
|
376
|
+
<span className="newspack-reorder-modal__grip" aria-hidden="true">
|
|
377
|
+
<Icon icon={ dragHandle } />
|
|
378
|
+
</span>
|
|
379
|
+
<span className="newspack-reorder-modal__move">
|
|
380
|
+
<Button
|
|
381
|
+
icon={ chevronUp }
|
|
382
|
+
size="small"
|
|
383
|
+
data-direction="up"
|
|
384
|
+
disabled={ 0 === index }
|
|
385
|
+
accessibleWhenDisabled
|
|
386
|
+
label={ moveUpLabel }
|
|
387
|
+
aria-label={ nameWithTitle( moveUpLabel, item.label ) }
|
|
388
|
+
onClick={ () => moveTo( index, index - 1, 'up' ) }
|
|
389
|
+
/>
|
|
390
|
+
<Button
|
|
391
|
+
icon={ chevronDown }
|
|
392
|
+
size="small"
|
|
393
|
+
data-direction="down"
|
|
394
|
+
disabled={ index === items.length - 1 }
|
|
395
|
+
accessibleWhenDisabled
|
|
396
|
+
label={ moveDownLabel }
|
|
397
|
+
aria-label={ nameWithTitle( moveDownLabel, item.label ) }
|
|
398
|
+
onClick={ () => moveTo( index, index + 1, 'down' ) }
|
|
399
|
+
/>
|
|
400
|
+
</span>
|
|
401
|
+
<Card.Content className="newspack-reorder-modal__content">
|
|
402
|
+
<span className="newspack-reorder-modal__title" title={ item.label }>
|
|
403
|
+
{ item.label }
|
|
404
|
+
</span>
|
|
405
|
+
{ item.badge && (
|
|
406
|
+
<span className="newspack-reorder-modal__badge">
|
|
407
|
+
{ item.badge.tooltip ? (
|
|
408
|
+
<Tooltip text={ item.badge.tooltip }>
|
|
409
|
+
{ /* Hover only: the toggle already carries the badge and tooltip text for keyboard and screen reader users. */ }
|
|
410
|
+
<span aria-hidden="true" tabIndex={ -1 }>
|
|
411
|
+
<Badge intent="draft">{ item.badge.label }</Badge>
|
|
412
|
+
</span>
|
|
413
|
+
</Tooltip>
|
|
414
|
+
) : (
|
|
415
|
+
<span aria-hidden="true">
|
|
416
|
+
<Badge intent="draft">{ item.badge.label }</Badge>
|
|
417
|
+
</span>
|
|
418
|
+
) }
|
|
419
|
+
<VisuallyHidden id={ `${ instanceId }-badge-${ item.id }` }>
|
|
420
|
+
{ item.badge.tooltip ? `${ item.badge.label }: ${ item.badge.tooltip }` : item.badge.label }
|
|
421
|
+
</VisuallyHidden>
|
|
422
|
+
</span>
|
|
423
|
+
) }
|
|
424
|
+
</Card.Content>
|
|
425
|
+
{ hasToggles && (
|
|
426
|
+
<span className="newspack-reorder-modal__toggle">
|
|
427
|
+
<FormToggle
|
|
428
|
+
checked={ enabled.has( item.id ) }
|
|
429
|
+
onChange={ () => toggleItem( item.id ) }
|
|
430
|
+
aria-label={ item.label }
|
|
431
|
+
aria-describedby={ item.badge ? `${ instanceId }-badge-${ item.id }` : undefined }
|
|
432
|
+
/>
|
|
433
|
+
</span>
|
|
434
|
+
) }
|
|
435
|
+
</Card.Root>
|
|
436
|
+
) ) }
|
|
437
|
+
</ul>
|
|
438
|
+
<div className="newspack-reorder-modal__footer">
|
|
439
|
+
<Button variant="tertiary" onClick={ requestClose }>
|
|
440
|
+
{ __( 'Cancel', 'newspack-plugin' ) }
|
|
441
|
+
</Button>
|
|
442
|
+
<Button
|
|
443
|
+
variant="primary"
|
|
444
|
+
disabled={ ! isDirty || isNoneOn }
|
|
445
|
+
accessibleWhenDisabled
|
|
446
|
+
// A button with visible children shows a tooltip only when asked and labelled.
|
|
447
|
+
label={ __( 'Save', 'newspack-plugin' ) }
|
|
448
|
+
showTooltip={ isDirty && isNoneOn }
|
|
449
|
+
description={ isDirty && isNoneOn ? noneOnHelp : undefined }
|
|
450
|
+
onClick={ () => {
|
|
451
|
+
const order = items.map( item => item.id );
|
|
452
|
+
if ( hasToggles ) {
|
|
453
|
+
onSave( order, [ ...order.filter( id => enabled.has( id ) ), ...enabledWithoutRow ] );
|
|
454
|
+
} else {
|
|
455
|
+
onSave( order );
|
|
456
|
+
}
|
|
457
|
+
} }
|
|
458
|
+
>
|
|
459
|
+
{ __( 'Save', 'newspack-plugin' ) }
|
|
460
|
+
</Button>
|
|
461
|
+
</div>
|
|
462
|
+
</>
|
|
463
|
+
) }
|
|
464
|
+
</div>
|
|
465
|
+
</Modal>
|
|
466
|
+
{ isConfirmingDiscard && (
|
|
467
|
+
<ConfirmDialog
|
|
468
|
+
isOpen
|
|
469
|
+
contentLabel={ hasToggles ? __( 'Discard the changes?', 'newspack-plugin' ) : __( 'Discard the new order?', 'newspack-plugin' ) }
|
|
470
|
+
confirmButtonText={ __( 'Discard', 'newspack-plugin' ) }
|
|
471
|
+
cancelButtonText={ __( 'Keep editing', 'newspack-plugin' ) }
|
|
472
|
+
onConfirm={ onClose }
|
|
473
|
+
onCancel={ () => setIsConfirmingDiscard( false ) }
|
|
474
|
+
>
|
|
475
|
+
{ hasToggles
|
|
476
|
+
? __( 'The changes you made will be lost.', 'newspack-plugin' )
|
|
477
|
+
: __( 'The order you set will be lost.', 'newspack-plugin' ) }
|
|
478
|
+
</ConfirmDialog>
|
|
479
|
+
) }
|
|
480
|
+
</>
|
|
481
|
+
);
|
|
482
|
+
};
|
|
483
|
+
|
|
484
|
+
export default ReorderModal;
|