@wordpress/block-library 9.41.1-next.v.202603102151.0 → 9.42.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 (140) hide show
  1. package/CHANGELOG.md +2 -0
  2. package/build/cover/edit/cover-placeholder.cjs +7 -0
  3. package/build/cover/edit/cover-placeholder.cjs.map +2 -2
  4. package/build/html/modal.cjs +151 -229
  5. package/build/html/modal.cjs.map +2 -2
  6. package/build/image/edit.cjs +7 -0
  7. package/build/image/edit.cjs.map +2 -2
  8. package/build/media-text/media-container.cjs +6 -0
  9. package/build/media-text/media-container.cjs.map +2 -2
  10. package/build/navigation/edit/index.cjs +5 -4
  11. package/build/navigation/edit/index.cjs.map +2 -2
  12. package/build/navigation-link/shared/use-link-preview.cjs +29 -0
  13. package/build/navigation-link/shared/use-link-preview.cjs.map +2 -2
  14. package/build/nextpage/block.json +0 -1
  15. package/build/playlist-track/block.json +0 -0
  16. package/build/post-date/block.json +1 -3
  17. package/build/post-date/deprecated.cjs +82 -6
  18. package/build/post-date/deprecated.cjs.map +3 -3
  19. package/build/post-date/edit.cjs +49 -62
  20. package/build/post-date/edit.cjs.map +3 -3
  21. package/build/site-logo/edit.cjs +1 -3
  22. package/build/site-logo/edit.cjs.map +2 -2
  23. package/build/site-title/index.cjs +5 -1
  24. package/build/site-title/index.cjs.map +2 -2
  25. package/build/tab/add-tab-toolbar-control.cjs +22 -5
  26. package/build/tab/add-tab-toolbar-control.cjs.map +2 -2
  27. package/build/tab/remove-tab-toolbar-control.cjs +19 -1
  28. package/build/tab/remove-tab-toolbar-control.cjs.map +2 -2
  29. package/build/tabs/edit.cjs +85 -7
  30. package/build/tabs/edit.cjs.map +2 -2
  31. package/build/tabs/index.cjs +12 -2
  32. package/build/tabs/index.cjs.map +2 -2
  33. package/build/tabs-menu/block.json +1 -6
  34. package/build/tabs-menu/edit.cjs +11 -151
  35. package/build/tabs-menu/edit.cjs.map +3 -3
  36. package/build/tabs-menu/save.cjs.map +2 -2
  37. package/build/tabs-menu-item/block.json +14 -11
  38. package/build/tabs-menu-item/controls.cjs +2 -133
  39. package/build/tabs-menu-item/controls.cjs.map +3 -3
  40. package/build/tabs-menu-item/edit.cjs +44 -56
  41. package/build/tabs-menu-item/edit.cjs.map +3 -3
  42. package/build/tabs-menu-item/save.cjs +0 -1
  43. package/build/tabs-menu-item/save.cjs.map +2 -2
  44. package/build/utils/media-control.cjs +72 -29
  45. package/build/utils/media-control.cjs.map +3 -3
  46. package/build-module/cover/edit/cover-placeholder.mjs +7 -0
  47. package/build-module/cover/edit/cover-placeholder.mjs.map +2 -2
  48. package/build-module/html/modal.mjs +151 -229
  49. package/build-module/html/modal.mjs.map +2 -2
  50. package/build-module/image/edit.mjs +7 -0
  51. package/build-module/image/edit.mjs.map +2 -2
  52. package/build-module/media-text/media-container.mjs +7 -1
  53. package/build-module/media-text/media-container.mjs.map +2 -2
  54. package/build-module/navigation/edit/index.mjs +5 -4
  55. package/build-module/navigation/edit/index.mjs.map +2 -2
  56. package/build-module/navigation-link/shared/use-link-preview.mjs +28 -0
  57. package/build-module/navigation-link/shared/use-link-preview.mjs.map +2 -2
  58. package/build-module/nextpage/block.json +0 -1
  59. package/build-module/playlist-track/block.json +0 -0
  60. package/build-module/post-date/block.json +1 -3
  61. package/build-module/post-date/deprecated.mjs +82 -6
  62. package/build-module/post-date/deprecated.mjs.map +2 -2
  63. package/build-module/post-date/edit.mjs +49 -63
  64. package/build-module/post-date/edit.mjs.map +2 -2
  65. package/build-module/site-logo/edit.mjs +1 -3
  66. package/build-module/site-logo/edit.mjs.map +2 -2
  67. package/build-module/site-title/index.mjs +5 -1
  68. package/build-module/site-title/index.mjs.map +2 -2
  69. package/build-module/tab/add-tab-toolbar-control.mjs +22 -5
  70. package/build-module/tab/add-tab-toolbar-control.mjs.map +2 -2
  71. package/build-module/tab/remove-tab-toolbar-control.mjs +19 -1
  72. package/build-module/tab/remove-tab-toolbar-control.mjs.map +2 -2
  73. package/build-module/tabs/edit.mjs +87 -9
  74. package/build-module/tabs/edit.mjs.map +2 -2
  75. package/build-module/tabs/index.mjs +12 -2
  76. package/build-module/tabs/index.mjs.map +2 -2
  77. package/build-module/tabs-menu/block.json +1 -6
  78. package/build-module/tabs-menu/edit.mjs +13 -162
  79. package/build-module/tabs-menu/edit.mjs.map +2 -2
  80. package/build-module/tabs-menu/save.mjs.map +2 -2
  81. package/build-module/tabs-menu-item/block.json +14 -11
  82. package/build-module/tabs-menu-item/controls.mjs +4 -143
  83. package/build-module/tabs-menu-item/controls.mjs.map +2 -2
  84. package/build-module/tabs-menu-item/edit.mjs +45 -57
  85. package/build-module/tabs-menu-item/edit.mjs.map +3 -3
  86. package/build-module/tabs-menu-item/save.mjs +0 -1
  87. package/build-module/tabs-menu-item/save.mjs.map +2 -2
  88. package/build-module/utils/media-control.mjs +73 -30
  89. package/build-module/utils/media-control.mjs.map +2 -2
  90. package/build-style/editor-rtl.css +45 -11
  91. package/build-style/editor.css +45 -11
  92. package/build-style/navigation/style-rtl.css +4 -0
  93. package/build-style/navigation/style.css +4 -0
  94. package/build-style/navigation-overlay-close/style-rtl.css +3 -3
  95. package/build-style/navigation-overlay-close/style.css +3 -3
  96. package/build-style/style-rtl.css +7 -3
  97. package/build-style/style.css +7 -3
  98. package/build-style/tabs-menu/editor-rtl.css +5 -3
  99. package/build-style/tabs-menu/editor.css +5 -3
  100. package/package.json +38 -38
  101. package/src/cover/edit/cover-placeholder.js +8 -0
  102. package/src/html/modal.js +6 -77
  103. package/src/image/edit.js +8 -0
  104. package/src/media-text/media-container.js +8 -1
  105. package/src/navigation/edit/index.js +6 -4
  106. package/src/navigation/index.php +24 -17
  107. package/src/navigation/style.scss +10 -0
  108. package/src/navigation-link/index.php +9 -9
  109. package/src/navigation-link/shared/test/use-link-preview.test.js +149 -0
  110. package/src/navigation-link/shared/use-link-preview.js +43 -1
  111. package/src/navigation-overlay-close/style.scss +3 -3
  112. package/src/navigation-submenu/index.php +17 -11
  113. package/src/nextpage/block.json +0 -1
  114. package/src/playlist-track/block.json +0 -0
  115. package/src/playlist-track/edit.js +0 -0
  116. package/src/playlist-track/index.js +0 -0
  117. package/src/playlist-track/index.php +0 -0
  118. package/src/playlist-track/init.js +0 -0
  119. package/src/playlist-track/style.scss +0 -0
  120. package/src/post-date/block.json +1 -3
  121. package/src/post-date/deprecated.js +86 -6
  122. package/src/post-date/edit.js +65 -82
  123. package/src/site-logo/edit.js +1 -3
  124. package/src/site-title/index.js +5 -1
  125. package/src/tab/add-tab-toolbar-control.js +48 -23
  126. package/src/tab/remove-tab-toolbar-control.js +30 -10
  127. package/src/tabs/edit.js +133 -10
  128. package/src/tabs/index.js +12 -2
  129. package/src/tabs-menu/block.json +1 -6
  130. package/src/tabs-menu/edit.js +13 -214
  131. package/src/tabs-menu/editor.scss +7 -3
  132. package/src/tabs-menu/index.php +42 -27
  133. package/src/tabs-menu/save.js +0 -4
  134. package/src/tabs-menu-item/block.json +14 -11
  135. package/src/tabs-menu-item/controls.js +4 -167
  136. package/src/tabs-menu-item/edit.js +60 -69
  137. package/src/tabs-menu-item/index.php +11 -23
  138. package/src/tabs-menu-item/save.js +0 -1
  139. package/src/utils/media-control.js +61 -21
  140. package/src/utils/media-control.scss +54 -18
package/src/html/modal.js CHANGED
@@ -37,8 +37,6 @@ export default function HTMLEditModal( {
37
37
  const [ editedHtml, setEditedHtml ] = useState( html );
38
38
  const [ editedCss, setEditedCss ] = useState( css );
39
39
  const [ editedJs, setEditedJs ] = useState( js );
40
- const [ isDirty, setIsDirty ] = useState( false );
41
- const [ showUnsavedWarning, setShowUnsavedWarning ] = useState( false );
42
40
  const [ isFullscreen, setIsFullscreen ] = useState( false );
43
41
 
44
42
  const isMobileViewport = useViewportMatch( 'small', '<' );
@@ -60,18 +58,6 @@ export default function HTMLEditModal( {
60
58
  return null;
61
59
  }
62
60
 
63
- const handleHtmlChange = ( value ) => {
64
- setEditedHtml( value );
65
- setIsDirty( true );
66
- };
67
- const handleCssChange = ( value ) => {
68
- setEditedCss( value );
69
- setIsDirty( true );
70
- };
71
- const handleJsChange = ( value ) => {
72
- setEditedJs( value );
73
- setIsDirty( true );
74
- };
75
61
  const handleUpdate = () => {
76
62
  // For users without unfiltered_html capability, strip CSS and JS content
77
63
  // to prevent kses from leaving broken content
@@ -82,25 +68,6 @@ export default function HTMLEditModal( {
82
68
  js: canUserUseUnfilteredHTML ? editedJs : '',
83
69
  } ),
84
70
  } );
85
- setIsDirty( false );
86
- };
87
- const handleCancel = () => {
88
- setIsDirty( false );
89
- onRequestClose();
90
- };
91
- const handleRequestClose = () => {
92
- if ( isDirty ) {
93
- setShowUnsavedWarning( true );
94
- } else {
95
- onRequestClose();
96
- }
97
- };
98
- const handleDiscardChanges = () => {
99
- setShowUnsavedWarning( false );
100
- onRequestClose();
101
- };
102
- const handleContinueEditing = () => {
103
- setShowUnsavedWarning( false );
104
71
  };
105
72
  const handleUpdateAndClose = () => {
106
73
  handleUpdate();
@@ -114,12 +81,11 @@ export default function HTMLEditModal( {
114
81
  <>
115
82
  <Modal
116
83
  title={ __( 'Edit HTML' ) }
117
- onRequestClose={ handleRequestClose }
84
+ onRequestClose={ onRequestClose }
118
85
  className="block-library-html__modal"
119
86
  size="large"
120
87
  isDismissible={ false }
121
- shouldCloseOnClickOutside={ ! isDirty }
122
- shouldCloseOnEsc={ ! isDirty }
88
+ shouldCloseOnClickOutside={ false }
123
89
  isFullScreen={ isFullscreen }
124
90
  __experimentalHideHeader
125
91
  >
@@ -183,7 +149,7 @@ export default function HTMLEditModal( {
183
149
  >
184
150
  <PlainText
185
151
  value={ editedHtml }
186
- onChange={ handleHtmlChange }
152
+ onChange={ setEditedHtml }
187
153
  placeholder={ __( 'Write HTML…' ) }
188
154
  aria-label={ __( 'HTML' ) }
189
155
  className="block-library-html__modal-editor"
@@ -197,7 +163,7 @@ export default function HTMLEditModal( {
197
163
  >
198
164
  <PlainText
199
165
  value={ editedCss }
200
- onChange={ handleCssChange }
166
+ onChange={ setEditedCss }
201
167
  placeholder={ __( 'Write CSS…' ) }
202
168
  aria-label={ __( 'CSS' ) }
203
169
  className="block-library-html__modal-editor"
@@ -212,7 +178,7 @@ export default function HTMLEditModal( {
212
178
  >
213
179
  <PlainText
214
180
  value={ editedJs }
215
- onChange={ handleJsChange }
181
+ onChange={ setEditedJs }
216
182
  placeholder={ __(
217
183
  'Write JavaScript…'
218
184
  ) }
@@ -241,7 +207,7 @@ export default function HTMLEditModal( {
241
207
  <Button
242
208
  __next40pxDefaultSize
243
209
  variant="tertiary"
244
- onClick={ handleCancel }
210
+ onClick={ onRequestClose }
245
211
  >
246
212
  { __( 'Cancel' ) }
247
213
  </Button>
@@ -256,43 +222,6 @@ export default function HTMLEditModal( {
256
222
  </VStack>
257
223
  </Tabs>
258
224
  </Modal>
259
-
260
- { showUnsavedWarning && (
261
- <Modal
262
- title={ __( 'Unsaved changes' ) }
263
- onRequestClose={ handleContinueEditing }
264
- size="medium"
265
- >
266
- <p>
267
- { __(
268
- 'You have unsaved changes. What would you like to do?'
269
- ) }
270
- </p>
271
- <Flex direction="row" justify="flex-end" gap={ 2 }>
272
- <Button
273
- __next40pxDefaultSize
274
- variant="secondary"
275
- onClick={ handleDiscardChanges }
276
- >
277
- { __( 'Discard unsaved changes' ) }
278
- </Button>
279
- <Button
280
- __next40pxDefaultSize
281
- variant="secondary"
282
- onClick={ handleContinueEditing }
283
- >
284
- { __( 'Continue editing' ) }
285
- </Button>
286
- <Button
287
- __next40pxDefaultSize
288
- variant="primary"
289
- onClick={ handleUpdateAndClose }
290
- >
291
- { __( 'Update and close' ) }
292
- </Button>
293
- </Flex>
294
- </Modal>
295
- ) }
296
225
  </>
297
226
  );
298
227
  }
package/src/image/edit.js CHANGED
@@ -159,6 +159,7 @@ export function ImageEdit( {
159
159
  const { createErrorNotice } = useDispatch( noticesStore );
160
160
  function onUploadError( message ) {
161
161
  createErrorNotice( message, { type: 'snackbar' } );
162
+ setTemporaryURL();
162
163
  setAttributes( {
163
164
  src: undefined,
164
165
  id: undefined,
@@ -167,6 +168,12 @@ export function ImageEdit( {
167
168
  } );
168
169
  }
169
170
 
171
+ function onFilesPreUpload( files ) {
172
+ if ( files.length === 1 ) {
173
+ setTemporaryURL( createBlobURL( files[ 0 ] ) );
174
+ }
175
+ }
176
+
170
177
  function onSelectImagesList( images ) {
171
178
  const win = containerRef.current?.ownerDocument.defaultView;
172
179
 
@@ -479,6 +486,7 @@ export function ImageEdit( {
479
486
  icon={ <BlockIcon icon={ icon } /> }
480
487
  onSelect={ onSelectImage }
481
488
  onSelectURL={ onSelectURL }
489
+ onFilesPreUpload={ onFilesPreUpload }
482
490
  onError={ onUploadError }
483
491
  placeholder={ placeholder }
484
492
  allowedTypes={ ALLOWED_MEDIA_TYPES }
@@ -18,7 +18,7 @@ import { __ } from '@wordpress/i18n';
18
18
  import { useViewportMatch } from '@wordpress/compose';
19
19
  import { useDispatch } from '@wordpress/data';
20
20
  import { forwardRef } from '@wordpress/element';
21
- import { isBlobURL } from '@wordpress/blob';
21
+ import { createBlobURL, isBlobURL } from '@wordpress/blob';
22
22
  import { store as noticesStore } from '@wordpress/notices';
23
23
  import { media as icon } from '@wordpress/icons';
24
24
 
@@ -82,6 +82,12 @@ function PlaceholderContainer( {
82
82
  createErrorNotice( message, { type: 'snackbar' } );
83
83
  };
84
84
 
85
+ const onFilesPreUpload = ( files ) => {
86
+ if ( files.length === 1 ) {
87
+ onSelectMedia( { url: createBlobURL( files[ 0 ] ) } );
88
+ }
89
+ };
90
+
85
91
  return (
86
92
  <MediaPlaceholder
87
93
  icon={ <BlockIcon icon={ icon } /> }
@@ -92,6 +98,7 @@ function PlaceholderContainer( {
92
98
  onSelect={ onSelectMedia }
93
99
  onToggleFeaturedImage={ toggleUseFeaturedImage }
94
100
  allowedTypes={ ALLOWED_MEDIA_TYPES }
101
+ onFilesPreUpload={ onFilesPreUpload }
95
102
  onError={ onUploadError }
96
103
  disableMediaButtons={ mediaUrl }
97
104
  />
@@ -86,7 +86,7 @@ import {
86
86
  NAVIGATION_OVERLAY_TEMPLATE_PART_AREA,
87
87
  } from '../constants';
88
88
 
89
- const { isIsolatedEditorKey } = unlock( blockEditorPrivateApis );
89
+ const { isNavigationPostEditorKey } = unlock( blockEditorPrivateApis );
90
90
 
91
91
  /**
92
92
  * Component that renders the Add page button for the Navigation block.
@@ -330,15 +330,17 @@ function Navigation( {
330
330
  } = useSelect( ( select ) => {
331
331
  const { getSettings } = select( blockEditorStore );
332
332
  const settings = getSettings();
333
+
333
334
  return {
334
335
  isPreviewMode: settings.isPreviewMode,
335
336
  onNavigateToEntityRecord: settings?.onNavigateToEntityRecord,
336
337
  // Needed to construct the template part ID for the overlay preview.
337
338
  currentTheme: select( coreStore ).getCurrentTheme()?.stylesheet,
338
- // In preview mode or isolated editor, always show navigation expanded (no hamburger)
339
- // so users can see and interact with all menu items.
339
+ // When editing a navigation post directly in an isolated editor,
340
+ // always show navigation expanded (no hamburger) so users can see
341
+ // and interact with all menu items.
340
342
  editorDisabledResponsive:
341
- settings.isPreviewMode || !! settings?.[ isIsolatedEditorKey ],
343
+ !! settings?.[ isNavigationPostEditorKey ],
342
344
  };
343
345
  }, [] );
344
346
  const hasAlreadyRendered = isPreviewMode ? false : recursionDetected;
@@ -697,14 +697,10 @@ class WP_Navigation_Block_Renderer {
697
697
  if ( ! empty( $attributes['overlay'] ) ) {
698
698
  // Get blocks from the overlay template part.
699
699
  $overlay_blocks = static::get_overlay_blocks_from_template_part( $attributes['overlay'], $attributes );
700
- // Check if overlay contains a navigation-overlay-close block.
701
- $has_custom_overlay_close_block = block_core_navigation_block_tree_has_block_type(
702
- $overlay_blocks,
703
- 'core/navigation-overlay-close',
704
- array( 'core/navigation' ) // Skip navigation blocks, as they cannot contain an overlay close block
705
- );
706
700
  // Render template part blocks directly without navigation container wrapper.
707
701
  $overlay_blocks_html = static::get_template_part_blocks_html( $overlay_blocks );
702
+ // Check if overlay contains a navigation-overlay-close block (detect in rendered HTML so it works with patterns).
703
+ $has_custom_overlay_close_block = block_core_navigation_overlay_html_has_close_block( $overlay_blocks_html );
708
704
  // Add Interactivity API directives to the overlay close block if present.
709
705
  if ( $has_custom_overlay_close_block && $is_interactive ) {
710
706
  $tags = new WP_HTML_Tag_Processor( $overlay_blocks_html );
@@ -1094,6 +1090,28 @@ if ( defined( 'IS_GUTENBERG_PLUGIN' ) && IS_GUTENBERG_PLUGIN ) {
1094
1090
  }
1095
1091
  }
1096
1092
 
1093
+ /**
1094
+ * Checks if the overlay HTML contains a navigation-overlay-close block.
1095
+ *
1096
+ * Uses WP_HTML_Tag_Processor to detect the close button in rendered output,
1097
+ * so it works when the overlay uses patterns (pattern content is rendered at
1098
+ * output time, not in the block tree).
1099
+ *
1100
+ * @since 7.0.0
1101
+ *
1102
+ * @param string $html The rendered overlay HTML.
1103
+ * @return bool True if a close button element is found.
1104
+ */
1105
+ function block_core_navigation_overlay_html_has_close_block( $html ) {
1106
+ $tags = new WP_HTML_Tag_Processor( $html );
1107
+ return $tags->next_tag(
1108
+ array(
1109
+ 'tag_name' => 'BUTTON',
1110
+ 'class_name' => 'wp-block-navigation-overlay-close',
1111
+ )
1112
+ );
1113
+ }
1114
+
1097
1115
  /**
1098
1116
  * Add Interactivity API directives to the navigation-overlay-close block
1099
1117
  * markup using the Tag Processor.
@@ -1326,17 +1344,6 @@ function block_core_navigation_build_css_font_sizes( $attributes ) {
1326
1344
  return $font_sizes;
1327
1345
  }
1328
1346
 
1329
- /**
1330
- * Returns the top-level submenu SVG chevron icon.
1331
- *
1332
- * @since 5.9.0
1333
- *
1334
- * @return string
1335
- */
1336
- function block_core_navigation_render_submenu_icon() {
1337
- return '<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 12 12" fill="none" aria-hidden="true" focusable="false"><path d="M1.50002 4L6.00002 8L10.5 4" stroke-width="1.5"></path></svg>';
1338
- }
1339
-
1340
1347
  /**
1341
1348
  * Filter out empty "null" blocks from the block list.
1342
1349
  * 'parse_blocks' includes a null block with '\n\n' as the content when
@@ -700,8 +700,18 @@ button.wp-block-navigation-item__content {
700
700
  display: none;
701
701
  }
702
702
 
703
+ // Override justification dropdown menu positioning rules inside custom overlays.
704
+ // Mirrors the protection at lines 674-678 for default overlays, which does not
705
+ // apply here. Prevents .items-justified-right descendant rules from cascading
706
+ // right: 0 into the overlay and anchoring submenus off the left edge of the viewport.
707
+ // See: https://github.com/WordPress/gutenberg/issues/76276
703
708
  .wp-block-navigation__overlay-container {
704
709
  display: block;
710
+
711
+ .wp-block-navigation__submenu-container {
712
+ right: auto;
713
+ left: 0;
714
+ }
705
715
  }
706
716
  }
707
717
  }
@@ -5,14 +5,8 @@
5
5
  * @package WordPress
6
6
  */
7
7
 
8
- // Path differs between source and build: './shared/' in source, './navigation-link/shared/' in build.
9
- if ( file_exists( __DIR__ . '/shared/item-should-render.php' ) ) {
10
- require_once __DIR__ . '/shared/item-should-render.php';
11
- require_once __DIR__ . '/shared/render-submenu-icon.php';
12
- } else {
13
- require_once __DIR__ . '/navigation-link/shared/item-should-render.php';
14
- require_once __DIR__ . '/navigation-link/shared/render-submenu-icon.php';
15
- }
8
+ require_once __DIR__ . '/navigation-link/shared/item-should-render.php';
9
+ require_once __DIR__ . '/navigation-link/shared/render-submenu-icon.php';
16
10
 
17
11
  /**
18
12
  * Build an array with CSS classes and inline styles defining the colors
@@ -262,7 +256,13 @@ function render_block_core_navigation_link( $attributes, $content, $block ) {
262
256
 
263
257
  if ( isset( $block->context['showSubmenuIcon'] ) && $block->context['showSubmenuIcon'] && $has_submenu ) {
264
258
  // The submenu icon can be hidden by a CSS rule on the Navigation Block.
265
- $html .= '<span class="wp-block-navigation__submenu-icon">' . block_core_navigation_render_submenu_icon() . '</span>';
259
+ $html .= '<span class="wp-block-navigation__submenu-icon">';
260
+ if ( defined( 'IS_GUTENBERG_PLUGIN' ) && IS_GUTENBERG_PLUGIN ) {
261
+ $html .= gutenberg_block_core_shared_navigation_render_submenu_icon();
262
+ } else {
263
+ $html .= block_core_shared_navigation_render_submenu_icon();
264
+ }
265
+ $html .= '</span>';
266
266
  }
267
267
 
268
268
  if ( $has_submenu ) {
@@ -36,6 +36,7 @@ jest.mock( '../../../lock-unlock', () => ( {
36
36
  import {
37
37
  computeDisplayUrl,
38
38
  computeBadges,
39
+ isHomepage,
39
40
  useLinkPreview,
40
41
  } from '../use-link-preview';
41
42
 
@@ -144,6 +145,74 @@ describe( 'computeDisplayUrl', () => {
144
145
  } );
145
146
  } );
146
147
 
148
+ describe( 'isHomepage', () => {
149
+ const host = 'homepage.com';
150
+ const homeUrl = 'https://' + host;
151
+
152
+ test.each( [
153
+ [ '/', homeUrl ],
154
+ [ '/', undefined ],
155
+ ] )( 'should return true for root path "%s"', ( url, homeUrlParam ) => {
156
+ expect( isHomepage( url, homeUrlParam ) ).toBe( true );
157
+ } );
158
+
159
+ // Check combinations of http/s and trailing slash
160
+ test.each( [
161
+ [ `http://${ host }`, homeUrl ],
162
+ [ `https://${ host }`, homeUrl ],
163
+ [ `http://${ host }/`, homeUrl ],
164
+ [ `https://${ host }/`, homeUrl ],
165
+ ] )( 'should return true for site URL "%s"', ( url ) => {
166
+ expect( isHomepage( url, homeUrl ) ).toBe( true );
167
+ } );
168
+
169
+ test.each( [
170
+ [ '', homeUrl ],
171
+ [ `https://${ host }`, '' ],
172
+ [ `https://${ host }`, undefined ],
173
+ ] )(
174
+ 'should return false when url or homeUrl is empty and not a / path',
175
+ ( url, homeUrlParam ) => {
176
+ expect( isHomepage( url, homeUrlParam ) ).toBe( false );
177
+ }
178
+ );
179
+
180
+ const subdomain = 'sub.' + host;
181
+ test.each( [
182
+ [ false, `http://${ subdomain }/`, homeUrl ],
183
+ [ false, `https://${ subdomain }`, homeUrl ],
184
+ [ true, `http://${ subdomain }/`, `https://${ subdomain }` ],
185
+ [ true, `https://${ subdomain }`, `https://${ subdomain }` ],
186
+ ] )(
187
+ 'should return %s for subdomain (url=%s, homeUrl=%s)',
188
+ ( expected, url, homeUrlParam ) => {
189
+ expect( isHomepage( url, homeUrlParam ) ).toBe( expected );
190
+ }
191
+ );
192
+
193
+ const path = '/wordpress';
194
+ const subdirHomeUrl = 'https://' + host + path;
195
+
196
+ test.each( [
197
+ [ `https://${ host }${ path }`, subdirHomeUrl ],
198
+ [ `https://${ host }${ path }/`, subdirHomeUrl ],
199
+ [ `http://${ host }${ path }`, subdirHomeUrl ],
200
+ [ `http://${ host }${ path }/`, subdirHomeUrl ],
201
+ ] )( 'should return true for subdirectory homepage "%s"', ( url ) => {
202
+ expect( isHomepage( url, subdirHomeUrl ) ).toBe( true );
203
+ } );
204
+
205
+ test.each( [
206
+ [ `https://${ host }/page`, homeUrl ],
207
+ [ '/page', homeUrl ],
208
+ [ `https://${ host }`, subdirHomeUrl ],
209
+ [ `https://${ host }/`, subdirHomeUrl ],
210
+ [ `https://${ host }${ path }/page`, subdirHomeUrl ],
211
+ ] )( 'should return false for non-homepage "%s"', ( url, homeUrlParam ) => {
212
+ expect( isHomepage( url, homeUrlParam ) ).toBe( false );
213
+ } );
214
+ } );
215
+
147
216
  describe( 'computeBadges', () => {
148
217
  describe( 'kind badges', () => {
149
218
  it( 'should show "External link" badge for external links', () => {
@@ -183,6 +252,67 @@ describe( 'computeBadges', () => {
183
252
  } );
184
253
  } );
185
254
 
255
+ it( 'should show "Homepage" badge for root path', () => {
256
+ const badges = computeBadges( {
257
+ url: '/',
258
+ isExternal: false,
259
+ } );
260
+
261
+ expect( badges ).toContainEqual( {
262
+ label: 'Homepage',
263
+ intent: 'default',
264
+ } );
265
+ } );
266
+
267
+ test.each( [
268
+ [ 'https://example.com' ],
269
+ [ 'https://example.com/' ],
270
+ [ 'http://example.com' ],
271
+ [ 'http://example.com/' ],
272
+ ] )( 'should show "Homepage" badge when url is "%s"', ( url ) => {
273
+ const badges = computeBadges( {
274
+ url,
275
+ homeUrl: 'https://example.com',
276
+ isExternal: false,
277
+ } );
278
+ expect( badges ).toContainEqual( {
279
+ label: 'Homepage',
280
+ intent: 'default',
281
+ } );
282
+ } );
283
+
284
+ test.each( [ [ 'https://sub.example.com/', 'https://example.com' ] ] )(
285
+ 'should not show Homepage badge when subdomain url "%s" does not match homeUrl "%s"',
286
+ ( url, homeUrl ) => {
287
+ const badges = computeBadges( {
288
+ url,
289
+ homeUrl,
290
+ isExternal: false,
291
+ } );
292
+ expect( badges ).not.toContainEqual( {
293
+ label: 'Homepage',
294
+ intent: 'default',
295
+ } );
296
+ }
297
+ );
298
+
299
+ test.each( [
300
+ [ 'https://sub.example.com/', 'https://sub.example.com' ],
301
+ ] )(
302
+ 'should show Homepage badge when subdomain url "%s" matches homeUrl "%s"',
303
+ ( url, homeUrl ) => {
304
+ const badges = computeBadges( {
305
+ url,
306
+ homeUrl,
307
+ isExternal: false,
308
+ } );
309
+ expect( badges ).toContainEqual( {
310
+ label: 'Homepage',
311
+ intent: 'default',
312
+ } );
313
+ }
314
+ );
315
+
186
316
  it( 'should show page badge for relative paths', () => {
187
317
  const badges = computeBadges( {
188
318
  url: '/relative-path',
@@ -269,6 +399,25 @@ it( 'should show "Internal link" badge for hash links even when type is present'
269
399
  } );
270
400
  } );
271
401
 
402
+ it( 'should show "Homepage" badge for root path even when type is present', () => {
403
+ const badges = computeBadges( {
404
+ url: '/',
405
+ type: 'page',
406
+ isExternal: false,
407
+ } );
408
+
409
+ // Should prioritize homepage detection over type
410
+ expect( badges ).toContainEqual( {
411
+ label: 'Homepage',
412
+ intent: 'default',
413
+ } );
414
+ // Should NOT show Page badge
415
+ expect( badges ).not.toContainEqual( {
416
+ label: 'Page',
417
+ intent: 'default',
418
+ } );
419
+ } );
420
+
272
421
  test.each( [
273
422
  [ 'www.test.com', 'URLs without protocol' ],
274
423
  [ 'google.com', 'domain-only URLs without protocol' ],
@@ -26,6 +26,41 @@ function capitalize( str ) {
26
26
  return str.charAt( 0 ).toUpperCase() + str.slice( 1 );
27
27
  }
28
28
 
29
+ /**
30
+ * Check if a URL points to the site homepage.
31
+ * Handles protocol (http/https) and trailing slash variations.
32
+ * Does not match subdomains unless they are the site URL.
33
+ *
34
+ * @param {string} url - The URL to check
35
+ * @param {string} homeUrl - The WordPress site URL
36
+ * @return {boolean} True if url is the homepage
37
+ */
38
+ export function isHomepage( url, homeUrl ) {
39
+ if ( url === '/' ) {
40
+ return true;
41
+ }
42
+ if ( ! url || ! homeUrl ) {
43
+ return false;
44
+ }
45
+ try {
46
+ const urlParsed = new URL( url, homeUrl );
47
+ const homeParsed = new URL( homeUrl );
48
+
49
+ // Same host, i.e. sub.homepage.com or homepage.com
50
+ if ( urlParsed.hostname !== homeParsed.hostname ) {
51
+ return false;
52
+ }
53
+
54
+ // Path must match site root (normalize trailing slash)
55
+ const urlPath = urlParsed.pathname.replace( /\/$/, '' );
56
+ const homePath = homeParsed.pathname.replace( /\/$/, '' );
57
+
58
+ return urlPath === homePath;
59
+ } catch {
60
+ return false;
61
+ }
62
+ }
63
+
29
64
  /**
30
65
  * Compute display URL - strips site URL if internal, shows full URL if external.
31
66
  *
@@ -79,6 +114,7 @@ export function computeDisplayUrl( { linkUrl, homeUrl } = {} ) {
79
114
  *
80
115
  * @param {Object} options - Options object
81
116
  * @param {string} options.url - Link URL
117
+ * @param {string} options.homeUrl - WordPress site URL (for homepage detection)
82
118
  * @param {string} options.type - Entity type (page, post, etc.)
83
119
  * @param {boolean} options.isExternal - Whether link is external
84
120
  * @param {string} options.entityStatus - Entity status (publish, draft, etc.)
@@ -88,6 +124,7 @@ export function computeDisplayUrl( { linkUrl, homeUrl } = {} ) {
88
124
  */
89
125
  export function computeBadges( {
90
126
  url,
127
+ homeUrl,
91
128
  type,
92
129
  isExternal,
93
130
  entityStatus,
@@ -95,7 +132,6 @@ export function computeBadges( {
95
132
  isEntityAvailable,
96
133
  } ) {
97
134
  const badges = [];
98
-
99
135
  // Kind badge
100
136
  if ( url ) {
101
137
  if ( isExternal ) {
@@ -110,6 +146,11 @@ export function computeBadges( {
110
146
  label: __( 'Internal link' ),
111
147
  intent: 'default',
112
148
  } );
149
+ } else if ( isHomepage( url, homeUrl ) ) {
150
+ badges.push( {
151
+ label: __( 'Homepage' ),
152
+ intent: 'default',
153
+ } );
113
154
  } else if ( type && type !== 'custom' ) {
114
155
  // Show entity type badge (page, post, category, etc.)
115
156
  // but not 'custom' since that's just a manual link
@@ -226,6 +267,7 @@ export function useLinkPreview( {
226
267
  // Compute badges
227
268
  const badges = computeBadges( {
228
269
  url,
270
+ homeUrl,
229
271
  type,
230
272
  isExternal,
231
273
  entityStatus: entityRecord?.status,
@@ -3,7 +3,7 @@
3
3
  align-items: center;
4
4
  justify-content: center;
5
5
  gap: 0.5em;
6
- padding: 0.5em;
6
+ padding: 0;
7
7
  border: none;
8
8
  background: transparent;
9
9
  cursor: pointer;
@@ -14,8 +14,8 @@
14
14
  }
15
15
 
16
16
  svg {
17
- width: 1.5em;
18
- height: 1.5em;
17
+ width: 24px;
18
+ height: 24px;
19
19
  fill: currentColor;
20
20
  display: block;
21
21
  flex-shrink: 0;
@@ -5,6 +5,9 @@
5
5
  * @package WordPress
6
6
  */
7
7
 
8
+ require_once __DIR__ . '/navigation-link/shared/item-should-render.php';
9
+ require_once __DIR__ . '/navigation-link/shared/render-submenu-icon.php';
10
+
8
11
  /**
9
12
  * Returns the submenu visibility value with backward compatibility
10
13
  * for the deprecated openSubmenusOnClick attribute.
@@ -46,15 +49,6 @@ function block_core_navigation_submenu_get_submenu_visibility( $context ) {
46
49
  return $submenu_visibility ?? 'hover';
47
50
  }
48
51
 
49
- // Path differs between source and build: '../navigation-link/shared/' in source, './navigation-link/shared/' in build.
50
- if ( file_exists( __DIR__ . '/../navigation-link/shared/item-should-render.php' ) ) {
51
- require_once __DIR__ . '/../navigation-link/shared/item-should-render.php';
52
- require_once __DIR__ . '/../navigation-link/shared/render-submenu-icon.php';
53
- } else {
54
- require_once __DIR__ . '/navigation-link/shared/item-should-render.php';
55
- require_once __DIR__ . '/navigation-link/shared/render-submenu-icon.php';
56
- }
57
-
58
52
  /**
59
53
  * Build an array with CSS classes and inline styles defining the font sizes
60
54
  * which will be applied to the navigation markup in the front-end.
@@ -240,7 +234,13 @@ function render_block_core_navigation_submenu( $attributes, $content, $block ) {
240
234
  if ( $show_submenu_indicators && $has_submenu ) {
241
235
  // The submenu icon is rendered in a button here
242
236
  // so that there's a clickable element to open the submenu.
243
- $html .= '<button aria-label="' . esc_attr( $aria_label ) . '" class="wp-block-navigation__submenu-icon wp-block-navigation-submenu__toggle" aria-expanded="false">' . block_core_navigation_render_submenu_icon() . '</button>';
237
+ $html .= '<button aria-label="' . esc_attr( $aria_label ) . '" class="wp-block-navigation__submenu-icon wp-block-navigation-submenu__toggle" aria-expanded="false">';
238
+ if ( defined( 'IS_GUTENBERG_PLUGIN' ) && IS_GUTENBERG_PLUGIN ) {
239
+ $html .= gutenberg_block_core_shared_navigation_render_submenu_icon();
240
+ } else {
241
+ $html .= block_core_shared_navigation_render_submenu_icon();
242
+ }
243
+ $html .= '</button>';
244
244
  }
245
245
  } else {
246
246
  $html .= '<button aria-label="' . esc_attr( $aria_label ) . '" class="wp-block-navigation-item__content wp-block-navigation-submenu__toggle" aria-expanded="false">';
@@ -262,7 +262,13 @@ function render_block_core_navigation_submenu( $attributes, $content, $block ) {
262
262
  $html .= '</button>';
263
263
 
264
264
  if ( $has_submenu ) {
265
- $html .= '<span class="wp-block-navigation__submenu-icon">' . block_core_navigation_render_submenu_icon() . '</span>';
265
+ $html .= '<span class="wp-block-navigation__submenu-icon">';
266
+ if ( defined( 'IS_GUTENBERG_PLUGIN' ) && IS_GUTENBERG_PLUGIN ) {
267
+ $html .= gutenberg_block_core_shared_navigation_render_submenu_icon();
268
+ } else {
269
+ $html .= block_core_shared_navigation_render_submenu_icon();
270
+ }
271
+ $html .= '</span>';
266
272
  }
267
273
  }
268
274