@wordpress/fields 0.31.1-next.v.202602111440.0 → 0.32.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (76) hide show
  1. package/CHANGELOG.md +2 -0
  2. package/README.md +1 -1
  3. package/build/actions/duplicate-post.cjs.map +1 -1
  4. package/build/actions/reorder-page.cjs.map +1 -1
  5. package/build/components/create-template-part-modal/index.cjs +2 -5
  6. package/build/components/create-template-part-modal/index.cjs.map +3 -3
  7. package/build/components/media-edit/index.cjs +372 -157
  8. package/build/components/media-edit/index.cjs.map +3 -3
  9. package/build/components/media-edit/use-moving-animation.cjs +75 -0
  10. package/build/components/media-edit/use-moving-animation.cjs.map +7 -0
  11. package/build/fields/featured-image/index.cjs +2 -1
  12. package/build/fields/featured-image/index.cjs.map +2 -2
  13. package/build/fields/template/hooks.cjs +96 -0
  14. package/build/fields/template/hooks.cjs.map +7 -0
  15. package/build/fields/template/index.cjs +2 -0
  16. package/build/fields/template/index.cjs.map +2 -2
  17. package/build/fields/template/template-edit.cjs +33 -114
  18. package/build/fields/template/template-edit.cjs.map +3 -3
  19. package/build/fields/template/template-view.cjs +68 -0
  20. package/build/fields/template/template-view.cjs.map +7 -0
  21. package/build/fields/template/utils.cjs +91 -0
  22. package/build/fields/template/utils.cjs.map +7 -0
  23. package/build-module/actions/duplicate-post.mjs.map +1 -1
  24. package/build-module/actions/reorder-page.mjs.map +1 -1
  25. package/build-module/components/create-template-part-modal/index.mjs +3 -6
  26. package/build-module/components/create-template-part-modal/index.mjs.map +2 -2
  27. package/build-module/components/media-edit/index.mjs +379 -159
  28. package/build-module/components/media-edit/index.mjs.map +2 -2
  29. package/build-module/components/media-edit/use-moving-animation.mjs +54 -0
  30. package/build-module/components/media-edit/use-moving-animation.mjs.map +7 -0
  31. package/build-module/fields/featured-image/index.mjs +2 -1
  32. package/build-module/fields/featured-image/index.mjs.map +2 -2
  33. package/build-module/fields/template/hooks.mjs +71 -0
  34. package/build-module/fields/template/hooks.mjs.map +7 -0
  35. package/build-module/fields/template/index.mjs +2 -0
  36. package/build-module/fields/template/index.mjs.map +2 -2
  37. package/build-module/fields/template/template-edit.mjs +36 -123
  38. package/build-module/fields/template/template-edit.mjs.map +2 -2
  39. package/build-module/fields/template/template-view.mjs +43 -0
  40. package/build-module/fields/template/template-view.mjs.map +7 -0
  41. package/build-module/fields/template/utils.mjs +65 -0
  42. package/build-module/fields/template/utils.mjs.map +7 -0
  43. package/build-style/style-rtl.css +69 -61
  44. package/build-style/style.css +69 -61
  45. package/build-types/actions/duplicate-post.d.ts +1 -1
  46. package/build-types/actions/duplicate-post.d.ts.map +1 -1
  47. package/build-types/actions/reorder-page.d.ts +1 -1
  48. package/build-types/actions/reorder-page.d.ts.map +1 -1
  49. package/build-types/components/create-template-part-modal/index.d.ts.map +1 -1
  50. package/build-types/components/media-edit/index.d.ts +1 -1
  51. package/build-types/components/media-edit/index.d.ts.map +1 -1
  52. package/build-types/components/media-edit/use-moving-animation.d.ts +13 -0
  53. package/build-types/components/media-edit/use-moving-animation.d.ts.map +1 -0
  54. package/build-types/fields/template/hooks.d.ts +10 -0
  55. package/build-types/fields/template/hooks.d.ts.map +1 -0
  56. package/build-types/fields/template/index.d.ts.map +1 -1
  57. package/build-types/fields/template/template-edit.d.ts.map +1 -1
  58. package/build-types/fields/template/template-view.d.ts +4 -0
  59. package/build-types/fields/template/template-view.d.ts.map +1 -0
  60. package/build-types/fields/template/utils.d.ts +28 -0
  61. package/build-types/fields/template/utils.d.ts.map +1 -0
  62. package/package.json +27 -26
  63. package/src/actions/duplicate-post.tsx +1 -1
  64. package/src/actions/reorder-page.tsx +1 -1
  65. package/src/components/create-template-part-modal/index.tsx +5 -14
  66. package/src/components/media-edit/index.tsx +420 -163
  67. package/src/components/media-edit/style.scss +83 -30
  68. package/src/components/media-edit/use-moving-animation.ts +77 -0
  69. package/src/fields/featured-image/index.tsx +1 -1
  70. package/src/fields/template/hooks.ts +121 -0
  71. package/src/fields/template/index.ts +2 -0
  72. package/src/fields/template/template-edit.tsx +38 -149
  73. package/src/fields/template/template-view.tsx +52 -0
  74. package/src/fields/template/utils.ts +119 -0
  75. package/src/style.scss +0 -1
  76. package/src/fields/template/style.scss +0 -34
@@ -14,6 +14,7 @@ import {
14
14
  __experimentalText as Text,
15
15
  __experimentalTruncate as Truncate,
16
16
  __experimentalVStack as VStack,
17
+ __experimentalHStack as HStack,
17
18
  BaseControl,
18
19
  Tooltip,
19
20
  VisuallyHidden,
@@ -28,7 +29,7 @@ import {
28
29
  useRef,
29
30
  useState,
30
31
  } from '@wordpress/element';
31
- import { __ } from '@wordpress/i18n';
32
+ import { __, sprintf } from '@wordpress/i18n';
32
33
  import {
33
34
  archive,
34
35
  audio,
@@ -36,6 +37,10 @@ import {
36
37
  file,
37
38
  closeSmall,
38
39
  error as errorIcon,
40
+ chevronUp,
41
+ chevronDown,
42
+ chevronLeft,
43
+ chevronRight,
39
44
  } from '@wordpress/icons';
40
45
  import {
41
46
  MediaUpload,
@@ -49,9 +54,27 @@ import { store as noticesStore } from '@wordpress/notices';
49
54
  */
50
55
  import { unlock } from '../../lock-unlock';
51
56
  import type { MediaEditProps } from '../../types';
57
+ import useMovingAnimation from './use-moving-animation';
52
58
 
53
59
  const { MediaUploadModal } = unlock( mediaUtilsPrivateApis );
54
60
 
61
+ function AnimatedMediaItem( {
62
+ children,
63
+ index,
64
+ className,
65
+ }: {
66
+ children: React.ReactNode;
67
+ index: number;
68
+ className?: string;
69
+ } ) {
70
+ const ref = useMovingAnimation( index );
71
+ return (
72
+ <div ref={ ref } className={ className }>
73
+ { children }
74
+ </div>
75
+ );
76
+ }
77
+
55
78
  type BlobItem = {
56
79
  id: string;
57
80
  source_url: string;
@@ -59,6 +82,13 @@ type BlobItem = {
59
82
  alt_text?: string;
60
83
  };
61
84
 
85
+ function normalizeValue( value: number | number[] | undefined ): number[] {
86
+ if ( Array.isArray( value ) ) {
87
+ return value;
88
+ }
89
+ return value ? [ value ] : [];
90
+ }
91
+
62
92
  /**
63
93
  * Conditional Media component that uses MediaUploadModal when experiment is enabled,
64
94
  * otherwise falls back to media-utils MediaUpload.
@@ -122,7 +152,9 @@ function MediaPickerButton( {
122
152
  const isBlob = attachment && isBlobURL( attachment.source_url );
123
153
  const mediaPickerButton = (
124
154
  <div
125
- className="fields__media-edit-picker-button"
155
+ className={ clsx( 'fields__media-edit-picker-button', {
156
+ 'has-attachment': attachment,
157
+ } ) }
126
158
  role="button"
127
159
  tabIndex={ 0 }
128
160
  onClick={ () => {
@@ -160,7 +192,11 @@ function MediaPickerButton( {
160
192
  if ( ! showTooltip ) {
161
193
  return mediaPickerButton;
162
194
  }
163
- return <Tooltip text={ label }>{ mediaPickerButton }</Tooltip>;
195
+ return (
196
+ <Tooltip text={ label } placement="top">
197
+ { mediaPickerButton }
198
+ </Tooltip>
199
+ );
164
200
  }
165
201
 
166
202
  const archiveMimeTypes = [
@@ -195,6 +231,54 @@ function MediaEditPlaceholder( props: {
195
231
  );
196
232
  }
197
233
 
234
+ function MoveButtons( {
235
+ itemId,
236
+ index,
237
+ totalItems,
238
+ isUploading,
239
+ moveItem,
240
+ orientation = 'vertical',
241
+ }: {
242
+ itemId: number;
243
+ index: number;
244
+ totalItems: number;
245
+ isUploading: boolean;
246
+ moveItem: ( id: number, direction: 'up' | 'down' ) => void;
247
+ orientation?: 'vertical' | 'horizontal';
248
+ } ) {
249
+ const isHorizontal = orientation === 'horizontal';
250
+ return (
251
+ <>
252
+ <Button
253
+ __next40pxDefaultSize
254
+ icon={ isHorizontal ? chevronLeft : chevronUp }
255
+ label={ isHorizontal ? __( 'Move left' ) : __( 'Move up' ) }
256
+ size="small"
257
+ disabled={ isUploading || index === 0 }
258
+ accessibleWhenDisabled
259
+ tooltipPosition="top"
260
+ onClick={ ( event: React.MouseEvent< HTMLButtonElement > ) => {
261
+ event.stopPropagation();
262
+ moveItem( itemId, 'up' );
263
+ } }
264
+ />
265
+ <Button
266
+ __next40pxDefaultSize
267
+ icon={ isHorizontal ? chevronRight : chevronDown }
268
+ label={ isHorizontal ? __( 'Move right' ) : __( 'Move down' ) }
269
+ size="small"
270
+ disabled={ isUploading || index === totalItems - 1 }
271
+ accessibleWhenDisabled
272
+ tooltipPosition="top"
273
+ onClick={ ( event: React.MouseEvent< HTMLButtonElement > ) => {
274
+ event.stopPropagation();
275
+ moveItem( itemId, 'down' );
276
+ } }
277
+ />
278
+ </>
279
+ );
280
+ }
281
+
198
282
  function MediaPreview( { attachment }: { attachment: MediaEditAttachment } ) {
199
283
  const url = attachment.source_url;
200
284
  const mimeType = attachment.mime_type || '';
@@ -222,9 +306,11 @@ interface MediaEditAttachmentsProps {
222
306
  addButtonLabel: string;
223
307
  multiple?: boolean;
224
308
  removeItem: ( itemId: number ) => void;
309
+ moveItem: ( itemId: number, direction: 'up' | 'down' ) => void;
225
310
  open: () => void;
226
311
  onFilesDrop: ( files: File[], attachmentId?: number ) => void;
227
312
  isUploading: boolean;
313
+ setTargetItemId: ( id?: number ) => void;
228
314
  }
229
315
 
230
316
  function ExpandedMediaEditAttachments( {
@@ -232,9 +318,11 @@ function ExpandedMediaEditAttachments( {
232
318
  addButtonLabel,
233
319
  multiple,
234
320
  removeItem,
321
+ moveItem,
235
322
  open,
236
323
  onFilesDrop,
237
324
  isUploading,
325
+ setTargetItemId,
238
326
  }: MediaEditAttachmentsProps ) {
239
327
  return (
240
328
  <div
@@ -244,20 +332,35 @@ function ExpandedMediaEditAttachments( {
244
332
  'is-empty': ! allItems?.length,
245
333
  } ) }
246
334
  >
247
- { allItems?.map( ( attachment ) => {
335
+ { allItems?.map( ( attachment, index ) => {
248
336
  const hasPreviewImage =
249
337
  attachment.mime_type?.startsWith( 'image' );
250
338
  const isBlob = isBlobURL( attachment.source_url );
339
+ const attachmentNumericId = attachment.id as number;
251
340
  return (
252
- <div
341
+ <AnimatedMediaItem
253
342
  key={ attachment.id }
343
+ index={ index }
254
344
  className={ clsx( 'fields__media-edit-expanded-item', {
255
345
  'has-preview-image': hasPreviewImage,
256
346
  } ) }
257
347
  >
258
348
  <MediaPickerButton
259
- open={ open }
260
- label={ __( 'Replace' ) }
349
+ open={ () => {
350
+ setTargetItemId( attachmentNumericId );
351
+ open();
352
+ } }
353
+ label={
354
+ ! isBlob
355
+ ? sprintf(
356
+ /* translators: %s: The title of the media item. */
357
+ __( 'Replace %s' ),
358
+ (
359
+ attachment as Attachment< 'view' >
360
+ ).title.rendered
361
+ )
362
+ : __( 'Replace' )
363
+ }
261
364
  showTooltip
262
365
  onFilesDrop={ onFilesDrop }
263
366
  attachment={ attachment }
@@ -275,52 +378,54 @@ function ExpandedMediaEditAttachments( {
275
378
  attachment={ attachment }
276
379
  />
277
380
  ) }
278
- { ! isBlob &&
279
- ( ! hasPreviewImage ? (
280
- <MediaTitle
281
- attachment={
282
- attachment as Attachment< 'view' >
283
- }
284
- />
285
- ) : (
286
- <div className="fields__media-edit-expanded-overlay">
287
- <div className="fields__media-edit-expanded-title">
288
- <MediaTitle
289
- attachment={
290
- attachment as Attachment< 'view' >
291
- }
292
- />
293
- </div>
294
- </div>
295
- ) ) }
296
381
  </VStack>
297
382
  </div>
298
383
  </MediaPickerButton>
299
384
  { ! isBlob && (
300
385
  <div className="fields__media-edit-expanded-overlay">
301
- <Button
302
- __next40pxDefaultSize
303
- className="fields__media-edit-expanded-remove"
304
- icon={ closeSmall }
305
- label={ __( 'Remove' ) }
306
- size="small"
307
- disabled={ isUploading }
308
- accessibleWhenDisabled
309
- onClick={ (
310
- event: React.MouseEvent< HTMLButtonElement >
311
- ) => {
312
- event.stopPropagation();
313
- removeItem( attachment.id as number );
314
- } }
315
- />
386
+ <HStack
387
+ className="fields__media-edit-expanded-actions"
388
+ spacing={ 0 }
389
+ alignment="flex-end"
390
+ expanded={ false }
391
+ >
392
+ { multiple && allItems.length > 1 && (
393
+ <MoveButtons
394
+ itemId={ attachmentNumericId }
395
+ index={ index }
396
+ totalItems={ allItems.length }
397
+ isUploading={ isUploading }
398
+ moveItem={ moveItem }
399
+ orientation="horizontal"
400
+ />
401
+ ) }
402
+ <Button
403
+ __next40pxDefaultSize
404
+ icon={ closeSmall }
405
+ label={ __( 'Remove' ) }
406
+ size="small"
407
+ disabled={ isUploading }
408
+ accessibleWhenDisabled
409
+ tooltipPosition="top"
410
+ onClick={ (
411
+ event: React.MouseEvent< HTMLButtonElement >
412
+ ) => {
413
+ event.stopPropagation();
414
+ removeItem( attachmentNumericId );
415
+ } }
416
+ />
417
+ </HStack>
316
418
  </div>
317
419
  ) }
318
- </div>
420
+ </AnimatedMediaItem>
319
421
  );
320
422
  } ) }
321
423
  { ( multiple || ! allItems?.length ) && (
322
424
  <MediaEditPlaceholder
323
- open={ open }
425
+ open={ () => {
426
+ setTargetItemId( undefined );
427
+ open();
428
+ } }
324
429
  label={ addButtonLabel }
325
430
  onFilesDrop={ onFilesDrop }
326
431
  isUploading={ isUploading }
@@ -335,68 +440,110 @@ function CompactMediaEditAttachments( {
335
440
  addButtonLabel,
336
441
  multiple,
337
442
  removeItem,
443
+ moveItem,
338
444
  open,
339
445
  onFilesDrop,
340
446
  isUploading,
447
+ setTargetItemId,
341
448
  }: MediaEditAttachmentsProps ) {
342
449
  return (
343
450
  <>
344
451
  { !! allItems?.length && (
345
- <VStack spacing={ 2 }>
346
- { allItems.map( ( attachment ) => {
347
- const isBlob = isBlobURL( attachment.source_url );
348
- return (
349
- <div
350
- key={ attachment.id }
351
- className="fields__media-edit-compact"
352
- >
353
- <MediaPickerButton
354
- open={ open }
355
- label={ __( 'Replace' ) }
356
- showTooltip
357
- onFilesDrop={ onFilesDrop }
358
- attachment={ attachment }
359
- isUploading={ isUploading }
452
+ <div
453
+ className={ clsx( 'fields__media-edit-compact-group', {
454
+ 'is-single': allItems.length === 1,
455
+ } ) }
456
+ >
457
+ <VStack spacing={ 0 }>
458
+ { allItems.map( ( attachment, index ) => {
459
+ const isBlob = isBlobURL( attachment.source_url );
460
+ const showMoveButtons =
461
+ multiple && allItems.length > 1;
462
+ const attachmentNumericId = attachment.id as number;
463
+ return (
464
+ <AnimatedMediaItem
465
+ key={ attachment.id }
466
+ index={ index }
467
+ className="fields__media-edit-compact"
360
468
  >
361
- <>
362
- <MediaPreview
363
- attachment={ attachment }
364
- />
365
- { ! isBlob && (
366
- <MediaTitle
367
- attachment={
368
- attachment as Attachment< 'view' >
369
- }
469
+ <MediaPickerButton
470
+ open={ () => {
471
+ setTargetItemId(
472
+ attachmentNumericId
473
+ );
474
+ open();
475
+ } }
476
+ label={ __( 'Replace' ) }
477
+ showTooltip
478
+ onFilesDrop={ onFilesDrop }
479
+ attachment={ attachment }
480
+ isUploading={ isUploading }
481
+ >
482
+ <>
483
+ <MediaPreview
484
+ attachment={ attachment }
370
485
  />
371
- ) }
372
- </>
373
- </MediaPickerButton>
374
- <Button
375
- __next40pxDefaultSize
376
- className="fields__media-edit-remove"
377
- text={ __( 'Remove' ) }
378
- variant="secondary"
379
- disabled={ isUploading }
380
- accessibleWhenDisabled
381
- onClick={ (
382
- event: React.MouseEvent< HTMLButtonElement >
383
- ) => {
384
- event.stopPropagation();
385
- if (
386
- typeof attachment.id === 'number'
387
- ) {
388
- removeItem( attachment.id );
389
- }
390
- } }
391
- />
392
- </div>
393
- );
394
- } ) }
395
- </VStack>
486
+ { ! isBlob && (
487
+ <MediaTitle
488
+ attachment={
489
+ attachment as Attachment< 'view' >
490
+ }
491
+ />
492
+ ) }
493
+ </>
494
+ </MediaPickerButton>
495
+ { ! isBlob && (
496
+ <HStack
497
+ className="fields__media-edit-compact-movers"
498
+ spacing={ 0 }
499
+ alignment="flex-end"
500
+ expanded={ false }
501
+ >
502
+ { showMoveButtons && (
503
+ <MoveButtons
504
+ itemId={
505
+ attachmentNumericId
506
+ }
507
+ index={ index }
508
+ totalItems={
509
+ allItems.length
510
+ }
511
+ isUploading={ isUploading }
512
+ moveItem={ moveItem }
513
+ orientation="vertical"
514
+ />
515
+ ) }
516
+ <Button
517
+ __next40pxDefaultSize
518
+ icon={ closeSmall }
519
+ label={ __( 'Remove' ) }
520
+ size="small"
521
+ disabled={ isUploading }
522
+ accessibleWhenDisabled
523
+ tooltipPosition="top"
524
+ onClick={ (
525
+ event: React.MouseEvent< HTMLButtonElement >
526
+ ) => {
527
+ event.stopPropagation();
528
+ removeItem(
529
+ attachmentNumericId
530
+ );
531
+ } }
532
+ />
533
+ </HStack>
534
+ ) }
535
+ </AnimatedMediaItem>
536
+ );
537
+ } ) }
538
+ </VStack>
539
+ </div>
396
540
  ) }
397
541
  { ( multiple || ! allItems?.length ) && (
398
542
  <MediaEditPlaceholder
399
- open={ open }
543
+ open={ () => {
544
+ setTargetItemId( undefined );
545
+ open();
546
+ } }
400
547
  label={ addButtonLabel }
401
548
  onFilesDrop={ onFilesDrop }
402
549
  isUploading={ isUploading }
@@ -425,7 +572,7 @@ function CompactMediaEditAttachments( {
425
572
  * @param {boolean} [props.hideLabelFromVision] - Whether the label should be hidden from vision.
426
573
  * @param {boolean} [props.isExpanded] - Whether to render in an expanded form. Default `false`.
427
574
  *
428
- * @return {JSX.Element} The media edit control component.
575
+ * @return {React.JSX.Element} The media edit control component.
429
576
  *
430
577
  * @example
431
578
  * ```tsx
@@ -477,18 +624,68 @@ export default function MediaEdit< Item >( {
477
624
  if ( ! value ) {
478
625
  return null;
479
626
  }
480
- const normalizedValue = Array.isArray( value ) ? value : [ value ];
627
+ const normalizedValue = normalizeValue( value );
628
+ // Sorted IDs ensure stable cache key, avoiding
629
+ // unnecessary new requests on reorder.
630
+ const sortedIds = [ ...normalizedValue ].sort( ( a, b ) => a - b );
481
631
  const { getEntityRecords } = select( coreStore );
482
632
  return getEntityRecords( 'postType', 'attachment', {
483
- include: normalizedValue,
633
+ include: sortedIds,
484
634
  } ) as Attachment< 'view' >[] | null;
485
635
  },
486
636
  [ value ]
487
637
  );
638
+ // Keep previous attachments during null transitions. When value changes,
639
+ // useSelect briefly returns null while the new query resolves. For pure
640
+ // reorders (same IDs), we fall back to the cached list to avoid a visual
641
+ // flash in compact mode. For replacements/uploads (new IDs not in cache),
642
+ // we let attachments be null as normal.
643
+ const stableAttachmentsRef = useRef< Attachment< 'view' >[] | null >(
644
+ null
645
+ );
646
+ if ( attachments !== null ) {
647
+ stableAttachmentsRef.current = attachments;
648
+ }
649
+ let stableAttachments = attachments;
650
+ if ( attachments === null && stableAttachmentsRef.current && value ) {
651
+ const stableIds = new Set(
652
+ stableAttachmentsRef.current.map( ( a ) => a.id )
653
+ );
654
+ if ( normalizeValue( value ).every( ( id ) => stableIds.has( id ) ) ) {
655
+ stableAttachments = stableAttachmentsRef.current;
656
+ }
657
+ }
658
+ // Reorder attachments to match value order.
659
+ const orderedAttachments = useMemo( () => {
660
+ if ( ! stableAttachments ) {
661
+ return null;
662
+ }
663
+ const normalizedValue = normalizeValue( value );
664
+ const attachmentMap = new Map(
665
+ stableAttachments.map( ( a ) => [ a.id, a ] )
666
+ );
667
+ return normalizedValue
668
+ .map( ( id ) => attachmentMap.get( id ) )
669
+ .filter( ( a ): a is Attachment< 'view' > => a !== undefined );
670
+ }, [ stableAttachments, value ] );
488
671
  const { createErrorNotice } = useDispatch( noticesStore );
672
+ const { receiveEntityRecords } = useDispatch( coreStore );
489
673
  // Support one upload action at a time for now.
490
- const [ replacementId, setReplacementId ] = useState< number >();
674
+ const [ targetItemId, setTargetItemId ] = useState< number >();
675
+ // Deferred open: the legacy class-based MediaUpload reads props
676
+ // imperatively when `open()` is called, so calling it in the same
677
+ // handler as `setTargetItemId()` would open the modal with stale
678
+ // `value`/`multiple` props. Setting a pending flag defers the open
679
+ // until after the next render when props are up to date.
680
+ const openModalRef = useRef< () => void >( undefined );
681
+ const [ pendingOpen, setPendingOpen ] = useState( false );
491
682
  const [ blobs, setBlobs ] = useState< string[] >( [] );
683
+ useEffect( () => {
684
+ if ( pendingOpen ) {
685
+ setPendingOpen( false );
686
+ openModalRef.current?.();
687
+ }
688
+ }, [ pendingOpen ] );
492
689
  const onChangeControl = useCallback(
493
690
  ( newValue: number | number[] | undefined ) =>
494
691
  onChange( field.setValue( { item: data, value: newValue } ) ),
@@ -496,7 +693,7 @@ export default function MediaEdit< Item >( {
496
693
  );
497
694
  const removeItem = useCallback(
498
695
  ( itemId: number ) => {
499
- const currentIds = Array.isArray( value ) ? value : [ value ];
696
+ const currentIds = normalizeValue( value );
500
697
  const newIds = currentIds.filter( ( id ) => id !== itemId );
501
698
  // Mark as touched to immediately show any validation error.
502
699
  setIsTouched( true );
@@ -504,65 +701,88 @@ export default function MediaEdit< Item >( {
504
701
  },
505
702
  [ value, onChangeControl ]
506
703
  );
704
+ const moveItem = useCallback(
705
+ ( itemId: number, direction: 'up' | 'down' ) => {
706
+ if ( ! orderedAttachments ) {
707
+ return;
708
+ }
709
+ const currentIds = orderedAttachments.map( ( a ) => a.id );
710
+ const index = currentIds.indexOf( itemId );
711
+ const newIndex = direction === 'up' ? index - 1 : index + 1;
712
+ [ currentIds[ index ], currentIds[ newIndex ] ] = [
713
+ currentIds[ newIndex ],
714
+ currentIds[ index ],
715
+ ];
716
+ onChangeControl( currentIds );
717
+ },
718
+ [ orderedAttachments, onChangeControl ]
719
+ );
507
720
  const onFilesDrop = useCallback(
508
- ( files: File[], _replacementId?: number ) => {
721
+ ( files: File[], _targetItemId?: number ) => {
722
+ setTargetItemId( _targetItemId );
509
723
  uploadMedia( {
510
724
  allowedTypes: allowedTypes?.length ? allowedTypes : undefined,
511
725
  filesList: files,
512
726
  onFileChange( uploadedMedia: any[] ) {
513
- setReplacementId( _replacementId );
514
- const { blobItems, uploadedItems } = uploadedMedia.reduce(
515
- ( accumulator, item ) => {
516
- if ( isBlobURL( item.url ) ) {
517
- accumulator.blobItems.push( item.url );
518
- } else {
519
- accumulator.uploadedItems.push( item.id );
520
- }
521
- return accumulator;
522
- },
523
- {
524
- blobItems: [] as string[],
525
- uploadedItems: [] as number[],
526
- }
527
- );
528
- setBlobs( blobItems );
529
- // If all uploads are complete reset the replacementId.
530
- if ( uploadedItems.length === uploadedMedia.length ) {
531
- setReplacementId( undefined );
532
- }
533
- if ( ! uploadedItems.length ) {
727
+ const blobUrls = uploadedMedia
728
+ .filter( ( item ) => isBlobURL( item.url ) )
729
+ .map( ( item ) => item.url );
730
+ setBlobs( blobUrls );
731
+ // Wait for all uploads to complete before updating value.
732
+ if ( !! blobUrls.length ) {
534
733
  return;
535
734
  }
735
+ // `uploadMedia` creates attachments via `apiFetch`
736
+ // outside the core-data store, so invalidate
737
+ // all attachment queries to keep them fresh for
738
+ // other components that rely on core-data.
739
+ receiveEntityRecords(
740
+ 'postType',
741
+ 'attachment',
742
+ [],
743
+ undefined,
744
+ true
745
+ );
746
+ const uploadedIds = uploadedMedia.map(
747
+ ( item ) => item.id
748
+ );
536
749
  if ( ! multiple ) {
537
- onChangeControl( uploadedItems[ 0 ] );
750
+ onChangeControl( uploadedIds[ 0 ] );
751
+ setTargetItemId( undefined );
538
752
  return;
539
753
  }
540
- if ( ! value ) {
541
- onChangeControl( uploadedItems );
542
- return;
754
+ const currentValue = normalizeValue( value );
755
+ // Dropped on placeholder: append new items.
756
+ if ( _targetItemId === undefined ) {
757
+ onChangeControl( [ ...currentValue, ...uploadedIds ] );
758
+ } else {
759
+ // Dropped on existing item: insert at that position.
760
+ const newValue = [ ...currentValue ];
761
+ newValue.splice(
762
+ currentValue.indexOf( _targetItemId ),
763
+ 1,
764
+ ...uploadedIds
765
+ );
766
+ onChangeControl( newValue );
543
767
  }
544
- const normalizedValue = Array.isArray( value )
545
- ? value
546
- : [ value ];
547
- const newIds = [
548
- ...( _replacementId
549
- ? normalizedValue.filter(
550
- ( id: any ) => id !== _replacementId
551
- )
552
- : normalizedValue ),
553
- ...uploadedItems,
554
- ];
555
- onChangeControl( newIds );
768
+ setTargetItemId( undefined );
556
769
  },
557
770
  onError( error: Error ) {
558
- setReplacementId( undefined );
771
+ setTargetItemId( undefined );
559
772
  setBlobs( [] );
560
773
  createErrorNotice( error.message, { type: 'snackbar' } );
561
774
  },
562
775
  multiple: !! multiple,
563
776
  } );
564
777
  },
565
- [ allowedTypes, value, multiple, createErrorNotice, onChangeControl ]
778
+ [
779
+ allowedTypes,
780
+ value,
781
+ multiple,
782
+ createErrorNotice,
783
+ onChangeControl,
784
+ receiveEntityRecords,
785
+ ]
566
786
  );
567
787
  const addButtonLabel =
568
788
  field.placeholder ||
@@ -570,31 +790,27 @@ export default function MediaEdit< Item >( {
570
790
  // Merge real attachments with any existing blob items that are being uploaded.
571
791
  const allItems: Array< MediaEditAttachment > | null = useMemo( () => {
572
792
  if ( ! blobs.length ) {
573
- return attachments;
793
+ return orderedAttachments;
574
794
  }
575
795
  const items: Array< MediaEditAttachment > = [
576
- ...( attachments || [] ),
796
+ ...( orderedAttachments || [] ),
577
797
  ];
578
798
  const blobItems = blobs.map( ( url ) => ( {
579
799
  id: url,
580
800
  source_url: url,
581
801
  mime_type: getBlobTypeByURL( url ),
582
802
  } ) );
583
- const replacementIndex = items.findIndex(
584
- ( a ) => a.id === replacementId
585
- );
586
- // Place blobs at the replacement index, when files
587
- // dropped in existing media item.
588
- if ( replacementIndex !== -1 ) {
589
- return [
590
- ...items.slice( 0, replacementIndex ),
591
- ...blobItems,
592
- ...items.slice( replacementIndex + 1 ),
593
- ];
803
+ if ( targetItemId !== undefined ) {
804
+ // When files are dropped in existing media item, place the blobs at that item.
805
+ const targetIndex = items.findIndex(
806
+ ( a ) => a.id === targetItemId
807
+ );
808
+ items.splice( targetIndex, 1, ...blobItems );
809
+ } else {
810
+ items.push( ...blobItems );
594
811
  }
595
- items.push( ...blobItems );
596
812
  return items;
597
- }, [ attachments, replacementId, blobs ] );
813
+ }, [ orderedAttachments, targetItemId, blobs ] );
598
814
  useEffect( () => {
599
815
  if ( ! isTouched ) {
600
816
  return;
@@ -614,7 +830,7 @@ export default function MediaEdit< Item >( {
614
830
  customValidityResult.message || __( 'Invalid' )
615
831
  );
616
832
  } else {
617
- input.setCustomValidity( '' ); // Clear validity
833
+ input.setCustomValidity( '' ); // Clear validity.
618
834
  }
619
835
  } else {
620
836
  // Clear any previous validation.
@@ -641,20 +857,56 @@ export default function MediaEdit< Item >( {
641
857
  <fieldset className="fields__media-edit" data-field-id={ field.id }>
642
858
  <ConditionalMediaUpload
643
859
  onSelect={ ( selectedMedia: any ) => {
644
- if ( multiple ) {
645
- const newIds = Array.isArray( selectedMedia )
646
- ? selectedMedia.map( ( m: any ) => m.id )
647
- : [ selectedMedia.id ];
648
- onChangeControl( newIds );
649
- } else {
860
+ if ( ! multiple ) {
650
861
  onChangeControl( selectedMedia.id );
862
+ setTargetItemId( undefined );
863
+ return;
864
+ }
865
+ const newIds = Array.isArray( selectedMedia )
866
+ ? selectedMedia.map( ( m: any ) => m.id )
867
+ : [ selectedMedia.id ];
868
+ const currentValue = normalizeValue( value );
869
+ if ( ! currentValue.length ) {
870
+ onChangeControl( newIds );
871
+ } else if ( targetItemId === undefined ) {
872
+ // Placeholder clicked: keep existing items that are
873
+ // still selected, then append newly selected items.
874
+ const existingItems = currentValue.filter( ( id ) =>
875
+ newIds.includes( id )
876
+ );
877
+ const newItems = newIds.filter(
878
+ ( id ) => ! currentValue.includes( id )
879
+ );
880
+ onChangeControl( [
881
+ ...existingItems,
882
+ ...newItems,
883
+ ] );
884
+ } else if ( selectedMedia.id !== targetItemId ) {
885
+ // Remove selected item from its old position, if it
886
+ // already exists in the value.
887
+ const filtered = currentValue.filter(
888
+ ( id ) => id !== selectedMedia.id
889
+ );
890
+ // Replace the clicked item with the selected one.
891
+ onChangeControl(
892
+ filtered.map( ( id ) =>
893
+ id === targetItemId ? selectedMedia.id : id
894
+ )
895
+ );
651
896
  }
897
+ setTargetItemId( undefined );
652
898
  } }
899
+ onClose={ () => setTargetItemId( undefined ) }
653
900
  allowedTypes={ allowedTypes }
654
- value={ value }
655
- multiple={ multiple }
901
+ // When replacing an existing item, pass only that item's ID
902
+ // and open in single-select mode so the user picks exactly
903
+ // one replacement, even if `multiple` is true.
904
+ value={ targetItemId !== undefined ? targetItemId : value }
905
+ multiple={ multiple && targetItemId === undefined }
656
906
  title={ field.label }
657
907
  render={ ( { open }: any ) => {
908
+ // Keep a ref to the latest `open` so the deferred effect can call it.
909
+ openModalRef.current = open;
658
910
  const AttachmentsComponent = isExpanded
659
911
  ? ExpandedMediaEditAttachments
660
912
  : CompactMediaEditAttachments;
@@ -666,7 +918,10 @@ export default function MediaEdit< Item >( {
666
918
  { field.label }
667
919
  </VisuallyHidden>
668
920
  ) : (
669
- <BaseControl.VisualLabel as="legend">
921
+ <BaseControl.VisualLabel
922
+ as="legend"
923
+ style={ { marginBottom: 0 } }
924
+ >
670
925
  { field.label }
671
926
  </BaseControl.VisualLabel>
672
927
  ) ) }
@@ -675,9 +930,11 @@ export default function MediaEdit< Item >( {
675
930
  addButtonLabel={ addButtonLabel }
676
931
  multiple={ multiple }
677
932
  removeItem={ removeItem }
678
- open={ open }
933
+ moveItem={ moveItem }
934
+ open={ () => setPendingOpen( true ) }
679
935
  onFilesDrop={ onFilesDrop }
680
936
  isUploading={ !! blobs.length }
937
+ setTargetItemId={ setTargetItemId }
681
938
  />
682
939
  { field.description && (
683
940
  <Text variant="muted">