@wordpress/editor 14.33.7 → 14.33.9

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,199 @@
1
+ /**
2
+ * WordPress dependencies
3
+ */
4
+ import { store as blockEditorStore } from '@wordpress/block-editor';
5
+
6
+ /**
7
+ * Internal dependencies
8
+ */
9
+ import postDataBindings from '../post-data';
10
+
11
+ describe( 'post-data bindings', () => {
12
+ describe( 'getValues', () => {
13
+ describe( 'for regular blocks using block context', () => {
14
+ let select;
15
+ beforeAll( () => {
16
+ select = ( store ) => {
17
+ if ( store === blockEditorStore ) {
18
+ return {
19
+ getBlockName: ( clientId ) =>
20
+ clientId === '123abc456'
21
+ ? 'core/post-date'
22
+ : undefined,
23
+ getBlockAttributes: () => ( {} ),
24
+ };
25
+ }
26
+ return {
27
+ getEditedEntityRecord: ( kind, name, recordId ) =>
28
+ name === 'post' && recordId === 123
29
+ ? {
30
+ date: '2024-03-02 00:00:00',
31
+ modified: '2025-06-07 00:00:00',
32
+ link: 'https://example.com/post',
33
+ unknown: 'Unknown field value',
34
+ }
35
+ : {},
36
+ };
37
+ };
38
+ } );
39
+
40
+ it( 'should return entity field values when they exist', () => {
41
+ const values = postDataBindings.getValues( {
42
+ select,
43
+ context: { postId: 123, postType: 'post' },
44
+ bindings: {
45
+ datetime: {
46
+ source: 'core/post-date',
47
+ args: { field: 'date' },
48
+ },
49
+ modified: {
50
+ source: 'core/post-date',
51
+ args: { field: 'modified' },
52
+ },
53
+ url: {
54
+ source: 'core/post-date',
55
+ args: { field: 'link' },
56
+ },
57
+ },
58
+ clientId: '123abc456',
59
+ } );
60
+
61
+ expect( values ).toStrictEqual( {
62
+ datetime: '2024-03-02 00:00:00',
63
+ modified: '2025-06-07 00:00:00',
64
+ url: 'https://example.com/post',
65
+ } );
66
+ } );
67
+
68
+ it( 'should fall back to field labels when entity value does not exist', () => {
69
+ const values = postDataBindings.getValues( {
70
+ select,
71
+ context: { postId: 456, postType: 'post' },
72
+ bindings: {
73
+ datetime: {
74
+ source: 'core/post-date',
75
+ args: { field: 'date' },
76
+ },
77
+ modified: {
78
+ source: 'core/post-date',
79
+ args: { field: 'modified' },
80
+ },
81
+ url: {
82
+ source: 'core/post-date',
83
+ args: { field: 'link' },
84
+ },
85
+ },
86
+ clientId: '123abc456',
87
+ } );
88
+
89
+ expect( values ).toStrictEqual( {
90
+ datetime: 'Post Date',
91
+ modified: 'Post Modified Date',
92
+ url: 'Post Link',
93
+ } );
94
+ } );
95
+
96
+ it( 'should return empty object for unknown fields', () => {
97
+ const values = postDataBindings.getValues( {
98
+ select,
99
+ context: { postId: 123, postType: 'post' },
100
+ bindings: {
101
+ content: {
102
+ source: 'core/post-date',
103
+ args: { field: 'unknown' },
104
+ },
105
+ },
106
+ clientId: '123abc456',
107
+ } );
108
+
109
+ expect( values.content ).toEqual( {} );
110
+ } );
111
+ } );
112
+
113
+ describe( 'for navigation blocks using block attributes', () => {
114
+ it( 'should use block attributes instead of context', () => {
115
+ const select = ( store ) => {
116
+ if ( store === blockEditorStore ) {
117
+ return {
118
+ getBlockName: () => 'core/navigation-link',
119
+ getBlockAttributes: () => ( {
120
+ id: 456,
121
+ type: 'page',
122
+ } ),
123
+ };
124
+ }
125
+ return {
126
+ getEditedEntityRecord: ( _kind, type, id ) => {
127
+ if ( type !== 'page' || id !== 456 ) {
128
+ return {};
129
+ }
130
+ return {
131
+ link: 'https://example.com/page',
132
+ };
133
+ },
134
+ };
135
+ };
136
+
137
+ const values = postDataBindings.getValues( {
138
+ select,
139
+ context: { postId: 123, postType: 'post' },
140
+ bindings: {
141
+ url: {
142
+ source: 'core/post-date',
143
+ args: { field: 'link' },
144
+ },
145
+ },
146
+ clientId: '123abc456',
147
+ } );
148
+
149
+ expect( values.url ).toBe( 'https://example.com/page' );
150
+ } );
151
+ } );
152
+ } );
153
+
154
+ describe( 'getFieldsList', () => {
155
+ it( 'should return the list of available post data fields when the Date block is selected', () => {
156
+ const select = () => ( {
157
+ getSelectedBlock: () => ( {
158
+ name: 'core/post-date',
159
+ } ),
160
+ } );
161
+
162
+ const fields = postDataBindings.getFieldsList( {
163
+ select,
164
+ } );
165
+
166
+ expect( fields ).toEqual( [
167
+ {
168
+ label: 'Post Date',
169
+ args: { field: 'date' },
170
+ type: 'string',
171
+ },
172
+ {
173
+ label: 'Post Modified Date',
174
+ args: { field: 'modified' },
175
+ type: 'string',
176
+ },
177
+ {
178
+ label: 'Post Link',
179
+ args: { field: 'link' },
180
+ type: 'string',
181
+ },
182
+ ] );
183
+ } );
184
+
185
+ it( 'should return an empty array when any other block than the Date block is selected', () => {
186
+ const select = () => ( {
187
+ getSelectedBlock: () => ( {
188
+ name: 'core/paragraph',
189
+ } ),
190
+ } );
191
+
192
+ const fields = postDataBindings.getFieldsList( {
193
+ select,
194
+ } );
195
+
196
+ expect( fields ).toEqual( [] );
197
+ } );
198
+ } );
199
+ } );
@@ -16,6 +16,7 @@ import {
16
16
  } from '@wordpress/components';
17
17
  import { __ } from '@wordpress/i18n';
18
18
  import { useInstanceId, useDebounce } from '@wordpress/compose';
19
+ import { isKeyboardEvent } from '@wordpress/keycodes';
19
20
 
20
21
  /**
21
22
  * Internal dependencies
@@ -50,6 +51,12 @@ function CommentForm( {
50
51
  <VStack
51
52
  className="editor-collab-sidebar-panel__comment-form"
52
53
  spacing="4"
54
+ as="form"
55
+ onSubmit={ ( event ) => {
56
+ event.preventDefault();
57
+ onSubmit( inputComment );
58
+ setInputComment( '' );
59
+ } }
53
60
  >
54
61
  <VisuallyHidden as="label" htmlFor={ inputId }>
55
62
  { labelText ?? __( 'Note' ) }
@@ -63,6 +70,14 @@ function CommentForm( {
63
70
  } }
64
71
  rows={ 1 }
65
72
  maxRows={ 20 }
73
+ onKeyDown={ ( event ) => {
74
+ if (
75
+ isKeyboardEvent.primary( event, 'Enter' ) &&
76
+ ! isDisabled
77
+ ) {
78
+ event.target.parentNode.requestSubmit();
79
+ }
80
+ } }
66
81
  />
67
82
  <HStack spacing="2" justify="flex-end" wrap>
68
83
  <Button size="compact" variant="tertiary" onClick={ onCancel }>
@@ -72,10 +87,7 @@ function CommentForm( {
72
87
  size="compact"
73
88
  accessibleWhenDisabled
74
89
  variant="primary"
75
- onClick={ () => {
76
- onSubmit( inputComment );
77
- setInputComment( '' );
78
- } }
90
+ type="submit"
79
91
  disabled={ isDisabled }
80
92
  >
81
93
  <Truncate>{ submitButtonText }</Truncate>
@@ -66,6 +66,9 @@ export function Comments( {
66
66
  const [ blockRefs, setBlockRefs ] = useState( {} );
67
67
 
68
68
  const { setCanvasMinHeight } = unlock( useDispatch( editorStore ) );
69
+ const { selectBlock, toggleBlockSpotlight } = unlock(
70
+ useDispatch( blockEditorStore )
71
+ );
69
72
  const { blockCommentId, selectedBlockClientId, orderedBlockIds } =
70
73
  useSelect( ( select ) => {
71
74
  const {
@@ -310,10 +313,84 @@ export function Comments( {
310
313
  setCanvasMinHeight,
311
314
  ] );
312
315
 
316
+ const handleThreadNavigation = ( event, thread, isSelected ) => {
317
+ if ( event.defaultPrevented ) {
318
+ return;
319
+ }
320
+
321
+ const currentIndex = threads.findIndex( ( t ) => t.id === thread.id );
322
+
323
+ if (
324
+ ( event.key === 'Enter' || event.key === 'ArrowRight' ) &&
325
+ event.currentTarget === event.target &&
326
+ ! isSelected
327
+ ) {
328
+ // Expand thread.
329
+ setNewNoteFormState( 'closed' );
330
+ setSelectedThread( thread.id );
331
+ if ( !! thread.blockClientId ) {
332
+ // Pass `null` as the second parameter to prevent focusing the block.
333
+ selectBlock( thread.blockClientId, null );
334
+ toggleBlockSpotlight( thread.blockClientId, true );
335
+ }
336
+ } else if (
337
+ ( ( event.key === 'Enter' || event.key === 'ArrowLeft' ) &&
338
+ event.currentTarget === event.target &&
339
+ isSelected ) ||
340
+ event.key === 'Escape'
341
+ ) {
342
+ // Collapse thread.
343
+ setSelectedThread( null );
344
+ setNewNoteFormState( 'closed' );
345
+ if ( thread.blockClientId ) {
346
+ toggleBlockSpotlight( thread.blockClientId, false );
347
+ }
348
+ focusCommentThread( thread.id, commentSidebarRef.current );
349
+ } else if (
350
+ event.key === 'ArrowDown' &&
351
+ currentIndex < threads.length - 1 &&
352
+ event.currentTarget === event.target
353
+ ) {
354
+ // Move to the next thread.
355
+ const nextThread = threads[ currentIndex + 1 ];
356
+ focusCommentThread( nextThread.id, commentSidebarRef.current );
357
+ } else if (
358
+ event.key === 'ArrowUp' &&
359
+ currentIndex > 0 &&
360
+ event.currentTarget === event.target
361
+ ) {
362
+ // Move to the previous thread.
363
+ const prevThread = threads[ currentIndex - 1 ];
364
+ focusCommentThread( prevThread.id, commentSidebarRef.current );
365
+ } else if (
366
+ event.key === 'Home' &&
367
+ event.currentTarget === event.target
368
+ ) {
369
+ // Move to the first thread.
370
+ focusCommentThread( threads[ 0 ].id, commentSidebarRef.current );
371
+ } else if (
372
+ event.key === 'End' &&
373
+ event.currentTarget === event.target
374
+ ) {
375
+ // Move to the last thread.
376
+ focusCommentThread(
377
+ threads[ threads.length - 1 ].id,
378
+ commentSidebarRef.current
379
+ );
380
+ }
381
+ };
382
+
313
383
  const hasThreads = Array.isArray( threads ) && threads.length > 0;
314
- // This should no longer happen since https://github.com/WordPress/gutenberg/pull/72872.
384
+ // A special case for `template-locked` mode - https://github.com/WordPress/gutenberg/pull/72646.
315
385
  if ( ! hasThreads && ! isFloating ) {
316
- return null;
386
+ return (
387
+ <AddComment
388
+ onSubmit={ onAddReply }
389
+ newNoteFormState={ newNoteFormState }
390
+ setNewNoteFormState={ setNewNoteFormState }
391
+ commentSidebarRef={ commentSidebarRef }
392
+ />
393
+ );
317
394
  }
318
395
 
319
396
  return (
@@ -345,6 +422,13 @@ export function Comments( {
345
422
  selectedThread={ selectedThread }
346
423
  commentLastUpdated={ commentLastUpdated }
347
424
  newNoteFormState={ newNoteFormState }
425
+ onKeyDown={ ( event ) =>
426
+ handleThreadNavigation(
427
+ event,
428
+ thread,
429
+ selectedThread === thread.id
430
+ )
431
+ }
348
432
  />
349
433
  ) ) }
350
434
  </>
@@ -368,6 +452,7 @@ function Thread( {
368
452
  selectedThread,
369
453
  commentLastUpdated,
370
454
  newNoteFormState,
455
+ onKeyDown,
371
456
  } ) {
372
457
  const { toggleBlockHighlight, selectBlock, toggleBlockSpotlight } = unlock(
373
458
  useDispatch( blockEditorStore )
@@ -385,6 +470,7 @@ function Thread( {
385
470
  selectedThread,
386
471
  commentLastUpdated,
387
472
  } );
473
+ const isKeyboardTabbingRef = useRef( false );
388
474
 
389
475
  const onMouseEnter = () => {
390
476
  debouncedToggleBlockHighlight( thread.blockClientId, true );
@@ -394,13 +480,48 @@ function Thread( {
394
480
  debouncedToggleBlockHighlight( thread.blockClientId, false );
395
481
  };
396
482
 
483
+ const onFocus = () => {
484
+ toggleBlockHighlight( thread.blockClientId, true );
485
+ };
486
+
487
+ const onBlur = ( event ) => {
488
+ const isNoteFocused = event.relatedTarget?.closest(
489
+ '.editor-collab-sidebar-panel__thread'
490
+ );
491
+ const isDialogFocused =
492
+ event.relatedTarget?.closest( '[role="dialog"]' );
493
+ const isTabbing = isKeyboardTabbingRef.current;
494
+
495
+ // When another note is clicked, do nothing because the current note is automatically closed.
496
+ if ( isNoteFocused && ! isTabbing ) {
497
+ return;
498
+ }
499
+ // When deleting a note, a dialog appears, but the note should not be collapsed.
500
+ if ( isDialogFocused ) {
501
+ return;
502
+ }
503
+ // When tabbing, do nothing if the focus is within the current note.
504
+ if (
505
+ isTabbing &&
506
+ event.currentTarget.contains( event.relatedTarget )
507
+ ) {
508
+ return;
509
+ }
510
+
511
+ // Closes a note that has lost focus when any of the following conditions are met:
512
+ // - An element other than a note is clicked.
513
+ // - Focus was lost by tabbing.
514
+ toggleBlockHighlight( thread.blockClientId, false );
515
+ unselectThread();
516
+ };
517
+
397
518
  const handleCommentSelect = () => {
398
519
  setNewNoteFormState( 'closed' );
399
520
  setSelectedThread( thread.id );
521
+ toggleBlockSpotlight( thread.blockClientId, true );
400
522
  if ( !! thread.blockClientId ) {
401
523
  // Pass `null` as the second parameter to prevent focusing the block.
402
524
  selectBlock( thread.blockClientId, null );
403
- toggleBlockSpotlight( thread.blockClientId, true );
404
525
  }
405
526
  };
406
527
 
@@ -462,27 +583,18 @@ function Thread( {
462
583
  onClick={ handleCommentSelect }
463
584
  onMouseEnter={ onMouseEnter }
464
585
  onMouseLeave={ onMouseLeave }
465
- onFocus={ onMouseEnter }
466
- onBlur={ onMouseLeave }
467
- onKeyDown={ ( event ) => {
468
- if ( event.defaultPrevented ) {
469
- return;
470
- }
471
- // Expand or Collapse thread.
472
- if (
473
- event.key === 'Enter' &&
474
- event.currentTarget === event.target
475
- ) {
476
- if ( isSelected ) {
477
- unselectThread();
478
- } else {
479
- handleCommentSelect();
480
- }
586
+ onFocus={ onFocus }
587
+ onBlur={ onBlur }
588
+ onKeyUp={ ( event ) => {
589
+ if ( event.key === 'Tab' ) {
590
+ isKeyboardTabbingRef.current = false;
481
591
  }
482
- // Collapse thread and focus the thread.
483
- if ( event.key === 'Escape' ) {
484
- unselectThread();
485
- focusCommentThread( thread.id, commentSidebarRef.current );
592
+ } }
593
+ onKeyDown={ ( event ) => {
594
+ if ( event.key === 'Tab' ) {
595
+ isKeyboardTabbingRef.current = true;
596
+ } else {
597
+ onKeyDown( event );
486
598
  }
487
599
  } }
488
600
  tabIndex={ 0 }
@@ -504,7 +616,7 @@ function Thread( {
504
616
  );
505
617
  } }
506
618
  >
507
- { __( 'Add new note' ) }
619
+ { __( 'Add new reply' ) }
508
620
  </Button>
509
621
  { ! thread.blockClientId && (
510
622
  <Text as="p" weight={ 500 } variant="muted">
@@ -709,6 +821,14 @@ const CommentBoard = ( {
709
821
  ? actions.filter( ( item ) => item.isEligible( thread ) )
710
822
  : [];
711
823
 
824
+ const deleteConfirmMessage =
825
+ // When deleting a top level note, descendants will also be deleted.
826
+ thread.parent === 0
827
+ ? __(
828
+ "Are you sure you want to delete this note? This will also delete all of this note's replies."
829
+ )
830
+ : __( 'Are you sure you want to delete this reply?' );
831
+
712
832
  return (
713
833
  <VStack
714
834
  spacing="2"
@@ -763,7 +883,12 @@ const CommentBoard = ( {
763
883
  />
764
884
  }
765
885
  />
766
- <Menu.Popover>
886
+ <Menu.Popover
887
+ // The menu popover is rendered in a portal, which causes focus to be
888
+ // lost and the note to be collapsed unintentionally. To prevent this,
889
+ // the popover should be rendered as an inline.
890
+ modal={ false }
891
+ >
767
892
  { moreActions.map( ( action ) => (
768
893
  <Menu.Item
769
894
  key={ action.id }
@@ -844,9 +969,7 @@ const CommentBoard = ( {
844
969
  onCancel={ handleCancel }
845
970
  confirmButtonText={ __( 'Delete' ) }
846
971
  >
847
- { __(
848
- "Are you sure you want to delete this note? This will also delete all of this note's replies."
849
- ) }
972
+ { deleteConfirmMessage }
850
973
  </ConfirmDialog>
851
974
  ) }
852
975
  </VStack>
@@ -53,14 +53,17 @@ export default function PostTemplatePanel() {
53
53
  return canCreateTemplates;
54
54
  }, [] );
55
55
 
56
- const canViewTemplates = useSelect( ( select ) => {
57
- return (
58
- select( coreStore ).canUser( 'read', {
59
- kind: 'postType',
60
- name: 'wp_template',
61
- } ) ?? false
62
- );
63
- }, [] );
56
+ const canViewTemplates = useSelect(
57
+ ( select ) => {
58
+ return isVisible
59
+ ? select( coreStore ).canUser( 'read', {
60
+ kind: 'postType',
61
+ name: 'wp_template',
62
+ } )
63
+ : false;
64
+ },
65
+ [ isVisible ]
66
+ );
64
67
 
65
68
  if ( ( ! isBlockTheme || ! canViewTemplates ) && isVisible ) {
66
69
  return <ClassicThemeControl />;