@wordpress/block-library 9.33.6 → 9.33.8

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 (66) hide show
  1. package/build/heading/index.js +3 -1
  2. package/build/heading/index.js.map +3 -3
  3. package/build/heading/variations.js +53 -0
  4. package/build/heading/variations.js.map +7 -0
  5. package/build/navigation-link/edit.js +31 -10
  6. package/build/navigation-link/edit.js.map +2 -2
  7. package/build/navigation-link/link-ui/index.js +8 -3
  8. package/build/navigation-link/link-ui/index.js.map +2 -2
  9. package/build/navigation-link/shared/controls.js +74 -23
  10. package/build/navigation-link/shared/controls.js.map +2 -2
  11. package/build/navigation-link/shared/index.js +4 -0
  12. package/build/navigation-link/shared/index.js.map +2 -2
  13. package/build/navigation-link/shared/use-entity-binding.js +32 -2
  14. package/build/navigation-link/shared/use-entity-binding.js.map +3 -3
  15. package/build/paragraph/index.js +3 -1
  16. package/build/paragraph/index.js.map +3 -3
  17. package/build/paragraph/variations.js +55 -0
  18. package/build/paragraph/variations.js.map +7 -0
  19. package/build/social-links/edit.js +1 -1
  20. package/build/social-links/edit.js.map +1 -1
  21. package/build-module/heading/index.js +3 -1
  22. package/build-module/heading/index.js.map +2 -2
  23. package/build-module/heading/variations.js +33 -0
  24. package/build-module/heading/variations.js.map +7 -0
  25. package/build-module/navigation-link/edit.js +45 -14
  26. package/build-module/navigation-link/edit.js.map +2 -2
  27. package/build-module/navigation-link/link-ui/index.js +8 -3
  28. package/build-module/navigation-link/link-ui/index.js.map +2 -2
  29. package/build-module/navigation-link/shared/controls.js +72 -23
  30. package/build-module/navigation-link/shared/controls.js.map +2 -2
  31. package/build-module/navigation-link/shared/index.js +3 -1
  32. package/build-module/navigation-link/shared/index.js.map +2 -2
  33. package/build-module/navigation-link/shared/use-entity-binding.js +36 -3
  34. package/build-module/navigation-link/shared/use-entity-binding.js.map +2 -2
  35. package/build-module/paragraph/index.js +3 -1
  36. package/build-module/paragraph/index.js.map +2 -2
  37. package/build-module/paragraph/variations.js +35 -0
  38. package/build-module/paragraph/variations.js.map +7 -0
  39. package/build-module/social-links/edit.js +1 -1
  40. package/build-module/social-links/edit.js.map +1 -1
  41. package/build-style/accordion-heading/style-rtl.css +19 -3
  42. package/build-style/accordion-heading/style.css +19 -3
  43. package/build-style/accordion-panel/style-rtl.css +4 -1
  44. package/build-style/accordion-panel/style.css +4 -1
  45. package/build-style/editor-rtl.css +7 -0
  46. package/build-style/editor.css +7 -0
  47. package/build-style/navigation-link/editor-rtl.css +7 -0
  48. package/build-style/navigation-link/editor.css +7 -0
  49. package/build-style/style-rtl.css +23 -4
  50. package/build-style/style.css +23 -4
  51. package/package.json +11 -11
  52. package/src/accordion-heading/style.scss +40 -7
  53. package/src/accordion-panel/style.scss +6 -1
  54. package/src/heading/index.js +2 -0
  55. package/src/heading/variations.js +37 -0
  56. package/src/navigation-link/edit.js +71 -17
  57. package/src/navigation-link/editor.scss +7 -0
  58. package/src/navigation-link/link-ui/index.js +9 -8
  59. package/src/navigation-link/shared/controls.js +120 -26
  60. package/src/navigation-link/shared/index.js +1 -1
  61. package/src/navigation-link/shared/test/controls.js +19 -9
  62. package/src/navigation-link/shared/test/use-entity-binding.js +14 -1
  63. package/src/navigation-link/shared/use-entity-binding.js +60 -9
  64. package/src/paragraph/index.js +2 -0
  65. package/src/paragraph/variations.js +39 -0
  66. package/src/social-links/edit.js +1 -1
@@ -0,0 +1,37 @@
1
+ /**
2
+ * WordPress dependencies
3
+ */
4
+ import { __ } from '@wordpress/i18n';
5
+ import { Path, SVG } from '@wordpress/primitives';
6
+ import { heading } from '@wordpress/icons';
7
+
8
+ const variations = [
9
+ {
10
+ name: 'heading',
11
+ title: __( 'Heading' ),
12
+ description: __(
13
+ 'Introduce new sections and organize content to help visitors (and search engines) understand the structure of your content.'
14
+ ),
15
+ isDefault: true,
16
+ scope: [ 'inserter', 'transform' ],
17
+ attributes: { fitText: undefined },
18
+ icon: heading,
19
+ },
20
+ // There is a hardcoded workaround in packages/block-editor/src/store/selectors.js
21
+ // to make Stretchy variations appear as the last of their sections in the inserter.
22
+ {
23
+ name: 'stretchy-heading',
24
+ title: __( 'Stretchy Heading' ),
25
+ description: __( 'Heading that resizes to fit its container.' ),
26
+ icon: (
27
+ <SVG xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
28
+ <Path d="m3 18.6 6-4.7 6 4.7V5H3v13.6Zm16.2-9.8v1.5h2.2L17.7 14l1.1 1.1 3.7-3.7v2.2H24V8.8h-4.8Z" />
29
+ </SVG>
30
+ ),
31
+ attributes: { fitText: true },
32
+ scope: [ 'inserter', 'transform' ],
33
+ isActive: ( blockAttributes ) => blockAttributes.fitText === true,
34
+ },
35
+ ];
36
+
37
+ export default variations;
@@ -8,9 +8,13 @@ import clsx from 'clsx';
8
8
  */
9
9
  import { createBlock } from '@wordpress/blocks';
10
10
  import { useSelect, useDispatch } from '@wordpress/data';
11
- import { ToolbarButton, ToolbarGroup } from '@wordpress/components';
11
+ import {
12
+ ToolbarButton,
13
+ ToolbarGroup,
14
+ VisuallyHidden,
15
+ } from '@wordpress/components';
12
16
  import { displayShortcut, isKeyboardEvent } from '@wordpress/keycodes';
13
- import { __ } from '@wordpress/i18n';
17
+ import { __, sprintf } from '@wordpress/i18n';
14
18
  import {
15
19
  BlockControls,
16
20
  InspectorControls,
@@ -26,13 +30,19 @@ import { useState, useEffect, useRef, useCallback } from '@wordpress/element';
26
30
  import { decodeEntities } from '@wordpress/html-entities';
27
31
  import { link as linkIcon, addSubmenu } from '@wordpress/icons';
28
32
  import { store as coreStore } from '@wordpress/core-data';
29
- import { useMergeRefs, usePrevious } from '@wordpress/compose';
33
+ import { useMergeRefs, usePrevious, useInstanceId } from '@wordpress/compose';
30
34
 
31
35
  /**
32
36
  * Internal dependencies
33
37
  */
34
38
  import { getColors } from '../navigation/edit/utils';
35
- import { Controls, LinkUI, updateAttributes, useEntityBinding } from './shared';
39
+ import {
40
+ Controls,
41
+ LinkUI,
42
+ updateAttributes,
43
+ useEntityBinding,
44
+ MissingEntityHelpText,
45
+ } from './shared';
36
46
 
37
47
  const DEFAULT_BLOCK = { name: 'core/navigation-link' };
38
48
  const NESTING_BLOCK_NAMES = [
@@ -97,21 +107,35 @@ const useIsInvalidLink = ( kind, type, id, enabled ) => {
97
107
  const hasId = Number.isInteger( id );
98
108
  const blockEditingMode = useBlockEditingMode();
99
109
 
100
- const postStatus = useSelect(
110
+ const { postStatus, isDeleted } = useSelect(
101
111
  ( select ) => {
102
112
  if ( ! isPostType ) {
103
- return null;
113
+ return { postStatus: null, isDeleted: false };
104
114
  }
105
115
 
106
116
  // Fetching the posts status is an "expensive" operation. Especially for sites with large navigations.
107
117
  // When the block is rendered in a template or other disabled contexts we can skip this check in order
108
118
  // to avoid all these additional requests that don't really add any value in that mode.
109
119
  if ( blockEditingMode === 'disabled' || ! enabled ) {
110
- return null;
120
+ return { postStatus: null, isDeleted: false };
111
121
  }
112
122
 
113
- const { getEntityRecord } = select( coreStore );
114
- return getEntityRecord( 'postType', type, id )?.status;
123
+ const { getEntityRecord, hasFinishedResolution } =
124
+ select( coreStore );
125
+ const entityRecord = getEntityRecord( 'postType', type, id );
126
+ const hasResolved = hasFinishedResolution( 'getEntityRecord', [
127
+ 'postType',
128
+ type,
129
+ id,
130
+ ] );
131
+
132
+ // If resolution has finished and entityRecord is undefined, the entity was deleted.
133
+ const deleted = hasResolved && entityRecord === undefined;
134
+
135
+ return {
136
+ postStatus: entityRecord?.status,
137
+ isDeleted: deleted,
138
+ };
115
139
  },
116
140
  [ isPostType, blockEditingMode, enabled, type, id ]
117
141
  );
@@ -121,11 +145,13 @@ const useIsInvalidLink = ( kind, type, id, enabled ) => {
121
145
  // 2. It has an id.
122
146
  // 3. It's neither null, nor undefined, as valid items might be either of those while loading.
123
147
  // If those conditions are met, check if
124
- // 1. The post status is published.
125
- // 2. The Navigation Link item has no label.
148
+ // 1. The post status is trash (trashed).
149
+ // 2. The entity doesn't exist (deleted).
126
150
  // If either of those is true, invalidate.
127
151
  const isInvalid =
128
- isPostType && hasId && postStatus && 'trash' === postStatus;
152
+ isPostType &&
153
+ hasId &&
154
+ ( isDeleted || ( postStatus && 'trash' === postStatus ) );
129
155
  const isDraft = 'draft' === postStatus;
130
156
 
131
157
  return [ isInvalid, isDraft ];
@@ -248,7 +274,12 @@ export default function NavigationLinkEdit( {
248
274
  const { getBlocks } = useSelect( blockEditorStore );
249
275
 
250
276
  // URL binding logic
251
- const { clearBinding, createBinding } = useEntityBinding( {
277
+ const {
278
+ clearBinding,
279
+ createBinding,
280
+ hasUrlBinding,
281
+ isBoundEntityAvailable,
282
+ } = useEntityBinding( {
252
283
  clientId,
253
284
  attributes,
254
285
  } );
@@ -373,6 +404,12 @@ export default function NavigationLinkEdit( {
373
404
  }
374
405
  }
375
406
 
407
+ const instanceId = useInstanceId( NavigationLinkEdit );
408
+ const hasMissingEntity = hasUrlBinding && ! isBoundEntityAvailable;
409
+ const missingEntityDescriptionId = hasMissingEntity
410
+ ? sprintf( 'navigation-link-edit-%d-desc', instanceId )
411
+ : undefined;
412
+
376
413
  const blockProps = useBlockProps( {
377
414
  ref: useMergeRefs( [ setPopoverAnchor, listItemRef ] ),
378
415
  className: clsx( 'wp-block-navigation-item', {
@@ -386,6 +423,8 @@ export default function NavigationLinkEdit( {
386
423
  [ getColorClassName( 'background-color', backgroundColor ) ]:
387
424
  !! backgroundColor,
388
425
  } ),
426
+ 'aria-describedby': missingEntityDescriptionId,
427
+ 'aria-invalid': hasMissingEntity,
389
428
  style: {
390
429
  color: ! textColor && customTextColor,
391
430
  backgroundColor: ! backgroundColor && customBackgroundColor,
@@ -405,14 +444,23 @@ export default function NavigationLinkEdit( {
405
444
  }
406
445
  );
407
446
 
408
- if ( ! url || isInvalid || isDraft ) {
447
+ if (
448
+ ! url ||
449
+ isInvalid ||
450
+ isDraft ||
451
+ ( hasUrlBinding && ! isBoundEntityAvailable )
452
+ ) {
409
453
  blockProps.onClick = () => {
410
454
  setIsLinkOpen( true );
411
455
  };
412
456
  }
413
457
 
414
458
  const classes = clsx( 'wp-block-navigation-item__content', {
415
- 'wp-block-navigation-link__placeholder': ! url || isInvalid || isDraft,
459
+ 'wp-block-navigation-link__placeholder':
460
+ ! url ||
461
+ isInvalid ||
462
+ isDraft ||
463
+ ( hasUrlBinding && ! isBoundEntityAvailable ),
416
464
  } );
417
465
 
418
466
  const missingText = getMissingText( type );
@@ -452,6 +500,11 @@ export default function NavigationLinkEdit( {
452
500
  />
453
501
  </InspectorControls>
454
502
  <div { ...blockProps }>
503
+ { hasMissingEntity && (
504
+ <VisuallyHidden id={ missingEntityDescriptionId }>
505
+ <MissingEntityHelpText type={ type } kind={ kind } />
506
+ </VisuallyHidden>
507
+ ) }
455
508
  { /* eslint-disable jsx-a11y/anchor-is-valid */ }
456
509
  <a className={ classes }>
457
510
  { /* eslint-enable */ }
@@ -531,9 +584,10 @@ export default function NavigationLinkEdit( {
531
584
  link={ attributes }
532
585
  onClose={ () => {
533
586
  setIsLinkOpen( false );
534
- // If there is no link then remove the auto-inserted block.
587
+ // If there is no link and no binding, remove the auto-inserted block.
535
588
  // This avoids empty blocks which can provided a poor UX.
536
- if ( ! url ) {
589
+ // Don't remove if binding exists (even if entity is unavailable) so user can fix it.
590
+ if ( ! url && ! hasUrlBinding ) {
537
591
  onReplace( [] );
538
592
  } else if ( isNewLink.current ) {
539
593
  // If we just created a new link, select it
@@ -143,3 +143,10 @@
143
143
  text-transform: uppercase;
144
144
  }
145
145
  }
146
+
147
+ /**
148
+ * Error text styling for missing entity help text.
149
+ */
150
+ .navigation-link-control__error-text {
151
+ color: $alert-red;
152
+ }
@@ -26,6 +26,7 @@ import { useInstanceId } from '@wordpress/compose';
26
26
  */
27
27
  import { LinkUIPageCreator } from './page-creator';
28
28
  import LinkUIBlockInserter from './block-inserter';
29
+ import { useEntityBinding } from '../shared/use-entity-binding';
29
30
 
30
31
  /**
31
32
  * Given the Link block's type attribute, return the query params to give to
@@ -66,7 +67,8 @@ export function getSuggestionsQuery( type, kind ) {
66
67
  }
67
68
 
68
69
  function UnforwardedLinkUI( props, ref ) {
69
- const { label, url, opensInNewTab, type, kind, id, metadata } = props.link;
70
+ const { label, url, opensInNewTab, type, kind, id } = props.link;
71
+ const { clientId } = props;
70
72
  const postType = type || 'page';
71
73
 
72
74
  const [ addingBlock, setAddingBlock ] = useState( false );
@@ -78,12 +80,11 @@ function UnforwardedLinkUI( props, ref ) {
78
80
  name: postType,
79
81
  } );
80
82
 
81
- // Check if there's a URL binding with the new binding sources
82
- // Only enable handleEntities when there's actually a binding present
83
- const hasUrlBinding =
84
- ( metadata?.bindings?.url?.source === 'core/post-data' ||
85
- metadata?.bindings?.url?.source === 'core/term-data' ) &&
86
- !! id;
83
+ // Use the entity binding hook to get binding status
84
+ const { isBoundEntityAvailable } = useEntityBinding( {
85
+ clientId,
86
+ attributes: props.link,
87
+ } );
87
88
 
88
89
  // Memoize link value to avoid overriding the LinkControl's internal state.
89
90
  // This is a temporary fix. See https://github.com/WordPress/gutenberg/issues/50976#issuecomment-1568226407.
@@ -152,7 +153,7 @@ function UnforwardedLinkUI( props, ref ) {
152
153
  onChange={ props.onChange }
153
154
  onRemove={ props.onRemove }
154
155
  onCancel={ props.onCancel }
155
- handleEntities={ hasUrlBinding }
156
+ handleEntities={ isBoundEntityAvailable }
156
157
  renderControlBottom={ () => {
157
158
  // Don't show the tools when there is submitted link (preview state).
158
159
  if ( link?.url?.length ) {
@@ -11,7 +11,7 @@ import {
11
11
  TextareaControl,
12
12
  } from '@wordpress/components';
13
13
  import { __, sprintf } from '@wordpress/i18n';
14
- import { useRef } from '@wordpress/element';
14
+ import { useRef, useEffect, useState } from '@wordpress/element';
15
15
  import { useInstanceId } from '@wordpress/compose';
16
16
  import { safeDecodeURI } from '@wordpress/url';
17
17
  import { __unstableStripHTML as stripHTML } from '@wordpress/dom';
@@ -72,19 +72,31 @@ export function Controls( { attributes, setAttributes, clientId } ) {
72
72
  const { label, url, description, rel, opensInNewTab } = attributes;
73
73
  const lastURLRef = useRef( url );
74
74
  const dropdownMenuProps = useToolsPanelDropdownMenuProps();
75
+ const urlInputRef = useRef();
76
+ const shouldFocusURLInputRef = useRef( false );
75
77
  const inputId = useInstanceId( Controls, 'link-input' );
76
78
  const helpTextId = `${ inputId }__help`;
77
79
 
80
+ // Local state to control the input value
81
+ const [ inputValue, setInputValue ] = useState( url );
82
+
83
+ // Sync local state when url prop changes (e.g., from undo/redo or external updates)
84
+ useEffect( () => {
85
+ setInputValue( url );
86
+ lastURLRef.current = url;
87
+ }, [ url ] );
88
+
78
89
  // Use the entity binding hook internally
79
- const { hasUrlBinding, clearBinding } = useEntityBinding( {
80
- clientId,
81
- attributes,
82
- } );
90
+ const { hasUrlBinding, isBoundEntityAvailable, clearBinding } =
91
+ useEntityBinding( {
92
+ clientId,
93
+ attributes,
94
+ } );
83
95
 
84
96
  // Get direct store dispatch to bypass setBoundAttributes wrapper
85
97
  const { updateBlockAttributes } = useDispatch( blockEditorStore );
86
98
 
87
- const editBoundLink = () => {
99
+ const unsyncBoundLink = () => {
88
100
  // Clear the binding first
89
101
  clearBinding();
90
102
 
@@ -93,9 +105,22 @@ export function Controls( { attributes, setAttributes, clientId } ) {
93
105
  // setAttributes is actually setBoundAttributes, a wrapper function that
94
106
  // processes attributes through the binding system.
95
107
  // See: packages/block-editor/src/components/block-edit/edit.js
96
- updateBlockAttributes( clientId, { url: '', id: undefined } );
108
+ updateBlockAttributes( clientId, {
109
+ url: lastURLRef.current, // set the lastURLRef as the new editable value so we avoid bugs from empty link states
110
+ id: undefined,
111
+ } );
97
112
  };
98
113
 
114
+ useEffect( () => {
115
+ // Only want to focus the input if the url is not bound to an entity.
116
+ if ( ! hasUrlBinding && shouldFocusURLInputRef.current ) {
117
+ // focuses and highlights the url input value, giving the user
118
+ // the ability to delete the value quickly or edit it.
119
+ urlInputRef.current?.select();
120
+ }
121
+ shouldFocusURLInputRef.current = false;
122
+ }, [ hasUrlBinding ] );
123
+
99
124
  return (
100
125
  <ToolsPanel
101
126
  label={ __( 'Settings' ) }
@@ -135,56 +160,100 @@ export function Controls( { attributes, setAttributes, clientId } ) {
135
160
  isShownByDefault
136
161
  >
137
162
  <InputControl
163
+ ref={ urlInputRef }
138
164
  __nextHasNoMarginBottom
139
165
  __next40pxDefaultSize
140
166
  id={ inputId }
141
167
  label={ __( 'Link' ) }
142
- value={ url ? safeDecodeURI( url ) : '' }
143
- onChange={ ( urlValue ) => {
144
- if ( hasUrlBinding ) {
145
- return; // Prevent editing when URL is bound
168
+ value={ ( () => {
169
+ if ( hasUrlBinding && ! isBoundEntityAvailable ) {
170
+ return '';
146
171
  }
147
- setAttributes( {
148
- url: encodeURI( safeDecodeURI( urlValue ) ),
149
- } );
150
- } }
172
+ return inputValue ? safeDecodeURI( inputValue ) : '';
173
+ } )() }
151
174
  autoComplete="off"
152
175
  type="url"
153
176
  disabled={ hasUrlBinding }
177
+ aria-invalid={
178
+ hasUrlBinding && ! isBoundEntityAvailable
179
+ ? 'true'
180
+ : undefined
181
+ }
182
+ aria-describedby={ helpTextId }
183
+ className={
184
+ hasUrlBinding && ! isBoundEntityAvailable
185
+ ? 'navigation-link-control__input-with-error-suffix'
186
+ : undefined
187
+ }
188
+ onChange={ ( newValue ) => {
189
+ if ( isBoundEntityAvailable ) {
190
+ return;
191
+ }
192
+
193
+ // Defer updating the url attribute until onBlur to prevent the canvas from
194
+ // treating a temporary empty value as a committed value, which replaces the
195
+ // label with placeholder text.
196
+ setInputValue( newValue );
197
+ } }
154
198
  onFocus={ () => {
155
- if ( hasUrlBinding ) {
199
+ if ( isBoundEntityAvailable ) {
156
200
  return;
157
201
  }
158
202
  lastURLRef.current = url;
159
203
  } }
160
204
  onBlur={ () => {
161
- if ( hasUrlBinding ) {
205
+ if ( isBoundEntityAvailable ) {
162
206
  return;
163
207
  }
208
+
209
+ const finalValue = ! inputValue
210
+ ? lastURLRef.current
211
+ : inputValue;
212
+
213
+ // Update local state immediately so input reflects the reverted value if the value was cleared
214
+ setInputValue( finalValue );
215
+
164
216
  // Defer the updateAttributes call to ensure entity connection isn't severed by accident.
165
- updateAttributes(
166
- { url: ! url ? lastURLRef.current : url },
167
- setAttributes,
168
- { ...attributes, url: lastURLRef.current }
169
- );
217
+ updateAttributes( { url: finalValue }, setAttributes, {
218
+ ...attributes,
219
+ url: lastURLRef.current,
220
+ } );
170
221
  } }
171
222
  help={
172
- hasUrlBinding && (
173
- <BindingHelpText
223
+ hasUrlBinding && ! isBoundEntityAvailable ? (
224
+ <MissingEntityHelp
225
+ id={ helpTextId }
174
226
  type={ attributes.type }
175
227
  kind={ attributes.kind }
176
228
  />
229
+ ) : (
230
+ isBoundEntityAvailable && (
231
+ <BindingHelpText
232
+ type={ attributes.type }
233
+ kind={ attributes.kind }
234
+ />
235
+ )
177
236
  )
178
237
  }
179
238
  suffix={
180
239
  hasUrlBinding && (
181
240
  <Button
182
241
  icon={ unlinkIcon }
183
- onClick={ editBoundLink }
242
+ onClick={ () => {
243
+ unsyncBoundLink();
244
+ // Focus management to send focus to the URL input
245
+ // on next render after disabled state is removed.
246
+ shouldFocusURLInputRef.current = true;
247
+ } }
184
248
  aria-describedby={ helpTextId }
185
249
  showTooltip
186
250
  label={ __( 'Unsync and edit' ) }
187
251
  __next40pxDefaultSize
252
+ className={
253
+ hasUrlBinding && ! isBoundEntityAvailable
254
+ ? 'navigation-link-control__error-suffix-button'
255
+ : undefined
256
+ }
188
257
  />
189
258
  )
190
259
  }
@@ -258,7 +327,7 @@ export function Controls( { attributes, setAttributes, clientId } ) {
258
327
  * @param {string} props.kind - The entity kind
259
328
  * @return {string} Help text for the bound URL
260
329
  */
261
- function BindingHelpText( { type, kind } ) {
330
+ export function BindingHelpText( { type, kind } ) {
262
331
  const entityType = getEntityTypeName( type, kind );
263
332
  return sprintf(
264
333
  /* translators: %s is the entity type (e.g., "page", "post", "category") */
@@ -266,3 +335,28 @@ function BindingHelpText( { type, kind } ) {
266
335
  entityType
267
336
  );
268
337
  }
338
+
339
+ /**
340
+ * Component to display error help text for missing entity bindings.
341
+ *
342
+ * @param {Object} props - Component props
343
+ * @param {string} props.type - The entity type
344
+ * @param {string} props.kind - The entity kind
345
+ * @return {JSX.Element} Error help text component
346
+ */
347
+ export function MissingEntityHelpText( { type, kind } ) {
348
+ const entityType = getEntityTypeName( type, kind );
349
+ return sprintf(
350
+ /* translators: %s is the entity type (e.g., "page", "post", "category") */
351
+ __( 'Synced %s is missing. Please update or remove this link.' ),
352
+ entityType
353
+ );
354
+ }
355
+
356
+ function MissingEntityHelp( { id, type, kind } ) {
357
+ return (
358
+ <span id={ id } className="navigation-link-control__error-text">
359
+ <MissingEntityHelpText type={ type } kind={ kind } />
360
+ </span>
361
+ );
362
+ }
@@ -5,7 +5,7 @@
5
5
  * to reduce code duplication and ensure consistent behavior.
6
6
  */
7
7
 
8
- export { Controls } from './controls';
8
+ export { Controls, BindingHelpText, MissingEntityHelpText } from './controls';
9
9
  export { updateAttributes } from './update-attributes';
10
10
  export {
11
11
  useEntityBinding,
@@ -6,6 +6,7 @@
6
6
  * External dependencies
7
7
  */
8
8
  import { render, screen, fireEvent } from '@testing-library/react';
9
+ import userEvent from '@testing-library/user-event';
9
10
 
10
11
  /**
11
12
  * Internal dependencies
@@ -27,6 +28,7 @@ jest.mock( '../../../utils/hooks', () => ( {
27
28
  jest.mock( '../use-entity-binding', () => ( {
28
29
  useEntityBinding: jest.fn( () => ( {
29
30
  hasUrlBinding: false,
31
+ isBoundEntityAvailable: false,
30
32
  clearBinding: jest.fn(),
31
33
  } ) ),
32
34
  } ) );
@@ -95,18 +97,22 @@ describe( 'Controls', () => {
95
97
  expect( urlInput.value ).toBe( 'https://example.com/test page' );
96
98
  } );
97
99
 
98
- it( 'encodes URL values when changed', () => {
100
+ it( 'calls updateAttributes with new URL on blur', async () => {
101
+ const user = userEvent.setup();
99
102
  render( <Controls { ...defaultProps } /> );
100
103
 
101
104
  const urlInput = screen.getByLabelText( 'Link' );
102
105
 
103
- fireEvent.change( urlInput, {
104
- target: { value: 'https://example.com/test page' },
105
- } );
106
+ await user.click( urlInput );
107
+ await user.clear( urlInput );
108
+ await user.type( urlInput, 'https://example.com/test page' );
109
+ await user.tab();
106
110
 
107
- expect( defaultProps.setAttributes ).toHaveBeenCalledWith( {
108
- url: 'https://example.com/test%20page',
109
- } );
111
+ expect( mockUpdateAttributes ).toHaveBeenCalledWith(
112
+ { url: 'https://example.com/test page' },
113
+ defaultProps.setAttributes,
114
+ { ...defaultProps.attributes, url: 'https://example.com' }
115
+ );
110
116
  } );
111
117
 
112
118
  it( 'calls updateAttributes on URL blur', () => {
@@ -143,11 +149,11 @@ describe( 'Controls', () => {
143
149
  target: { value: 'https://new.com' },
144
150
  } );
145
151
 
146
- // Blur should call updateAttributes with the current URL (since url exists)
152
+ // Blur should call updateAttributes with the new URL value from the input
147
153
  fireEvent.blur( urlInput );
148
154
 
149
155
  expect( mockUpdateAttributes ).toHaveBeenCalledWith(
150
- { url: 'https://different.com' }, // Current URL from attributes (not input value)
156
+ { url: 'https://new.com' }, // New URL from input value
151
157
  defaultProps.setAttributes,
152
158
  {
153
159
  ...propsWithDifferentUrl.attributes,
@@ -197,6 +203,7 @@ describe( 'Controls', () => {
197
203
  const { useEntityBinding } = require( '../use-entity-binding' );
198
204
  useEntityBinding.mockReturnValue( {
199
205
  hasUrlBinding: true,
206
+ isBoundEntityAvailable: true,
200
207
  clearBinding: jest.fn(),
201
208
  } );
202
209
 
@@ -220,6 +227,7 @@ describe( 'Controls', () => {
220
227
  const { useEntityBinding } = require( '../use-entity-binding' );
221
228
  useEntityBinding.mockReturnValue( {
222
229
  hasUrlBinding: true,
230
+ isBoundEntityAvailable: true,
223
231
  clearBinding: jest.fn(),
224
232
  } );
225
233
 
@@ -257,6 +265,7 @@ describe( 'Controls', () => {
257
265
  const { useEntityBinding } = require( '../use-entity-binding' );
258
266
  useEntityBinding.mockReturnValue( {
259
267
  hasUrlBinding: true,
268
+ isBoundEntityAvailable: true,
260
269
  clearBinding: jest.fn(),
261
270
  } );
262
271
 
@@ -280,6 +289,7 @@ describe( 'Controls', () => {
280
289
  const { useEntityBinding } = require( '../use-entity-binding' );
281
290
  useEntityBinding.mockReturnValue( {
282
291
  hasUrlBinding: true,
292
+ isBoundEntityAvailable: true,
283
293
  clearBinding: jest.fn(),
284
294
  } );
285
295
 
@@ -18,12 +18,23 @@ import {
18
18
  // Mock the entire @wordpress/block-editor module
19
19
  jest.mock( '@wordpress/block-editor', () => ( {
20
20
  useBlockBindingsUtils: jest.fn(),
21
+ useBlockEditingMode: jest.fn(),
21
22
  } ) );
22
23
 
24
+ // Mock useSelect specifically to avoid needing to set up full data store
25
+ jest.mock( '@wordpress/data/src/components/use-select', () => {
26
+ const mock = jest.fn();
27
+ return mock;
28
+ } );
29
+
23
30
  /**
24
31
  * WordPress dependencies
25
32
  */
26
- import { useBlockBindingsUtils } from '@wordpress/block-editor';
33
+ import {
34
+ useBlockBindingsUtils,
35
+ useBlockEditingMode,
36
+ } from '@wordpress/block-editor';
37
+ import { useSelect } from '@wordpress/data';
27
38
 
28
39
  describe( 'useEntityBinding', () => {
29
40
  const mockUpdateBlockBindings = jest.fn();
@@ -33,6 +44,8 @@ describe( 'useEntityBinding', () => {
33
44
  useBlockBindingsUtils.mockReturnValue( {
34
45
  updateBlockBindings: mockUpdateBlockBindings,
35
46
  } );
47
+ useBlockEditingMode.mockReturnValue( 'default' );
48
+ useSelect.mockReturnValue( true );
36
49
  } );
37
50
 
38
51
  describe( 'hasUrlBinding', () => {