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.
@@ -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.10.0-alpha.1",
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-alpha.1",
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-alpha.1",
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 { Card } from '../';
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;