@wordpress/block-editor 15.12.0 → 15.12.2-next.v.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 (149) hide show
  1. package/CHANGELOG.md +6 -0
  2. package/build/components/block-allowed-blocks/modal.cjs +1 -1
  3. package/build/components/block-allowed-blocks/modal.cjs.map +2 -2
  4. package/build/components/block-inspector/index.cjs +9 -9
  5. package/build/components/block-inspector/index.cjs.map +3 -3
  6. package/build/components/block-removal-warning-modal/index.cjs +30 -5
  7. package/build/components/block-removal-warning-modal/index.cjs.map +3 -3
  8. package/build/components/block-visibility/use-block-visibility.cjs +14 -29
  9. package/build/components/block-visibility/use-block-visibility.cjs.map +2 -2
  10. package/build/components/global-styles/hooks.cjs +7 -0
  11. package/build/components/global-styles/hooks.cjs.map +2 -2
  12. package/build/components/global-styles/typography-panel.cjs +71 -3
  13. package/build/components/global-styles/typography-panel.cjs.map +3 -3
  14. package/build/components/grid/grid-visualizer.cjs +49 -13
  15. package/build/components/grid/grid-visualizer.cjs.map +2 -2
  16. package/build/components/iframe/index.cjs +3 -1
  17. package/build/components/iframe/index.cjs.map +2 -2
  18. package/build/components/iframe/use-scale-canvas.cjs +1 -0
  19. package/build/components/iframe/use-scale-canvas.cjs.map +2 -2
  20. package/build/components/inspector-controls/last-item.cjs +41 -0
  21. package/build/components/inspector-controls/last-item.cjs.map +7 -0
  22. package/build/components/inspector-controls-tabs/styles-tab.cjs +3 -3
  23. package/build/components/inspector-controls-tabs/styles-tab.cjs.map +2 -2
  24. package/build/components/link-control/index.cjs +73 -2
  25. package/build/components/link-control/index.cjs.map +3 -3
  26. package/build/components/link-control/is-url-like.cjs +15 -3
  27. package/build/components/link-control/is-url-like.cjs.map +2 -2
  28. package/build/components/link-control/search-input.cjs +4 -1
  29. package/build/components/link-control/search-input.cjs.map +2 -2
  30. package/build/components/link-control/use-search-handler.cjs +1 -1
  31. package/build/components/link-control/use-search-handler.cjs.map +2 -2
  32. package/build/components/provider/use-block-sync.cjs +60 -8
  33. package/build/components/provider/use-block-sync.cjs.map +2 -2
  34. package/build/components/text-indent-control/index.cjs +121 -0
  35. package/build/components/text-indent-control/index.cjs.map +7 -0
  36. package/build/components/url-input/index.cjs +22 -2
  37. package/build/components/url-input/index.cjs.map +3 -3
  38. package/build/components/url-popover/image-url-input-ui.cjs +1 -1
  39. package/build/components/url-popover/image-url-input-ui.cjs.map +2 -2
  40. package/build/components/writing-flow/use-arrow-nav.cjs +0 -3
  41. package/build/components/writing-flow/use-arrow-nav.cjs.map +2 -2
  42. package/build/hooks/anchor.cjs +1 -1
  43. package/build/hooks/anchor.cjs.map +1 -1
  44. package/build/hooks/aria-label.cjs +2 -1
  45. package/build/hooks/aria-label.cjs.map +2 -2
  46. package/build/hooks/grid-visualizer.cjs +59 -6
  47. package/build/hooks/grid-visualizer.cjs.map +3 -3
  48. package/build/hooks/layout-child.cjs +47 -6
  49. package/build/hooks/layout-child.cjs.map +3 -3
  50. package/build/hooks/typography.cjs +2 -0
  51. package/build/hooks/typography.cjs.map +2 -2
  52. package/build/hooks/utils.cjs +4 -0
  53. package/build/hooks/utils.cjs.map +2 -2
  54. package/build/private-apis.cjs +2 -0
  55. package/build/private-apis.cjs.map +3 -3
  56. package/build/store/actions.cjs +2 -2
  57. package/build/store/actions.cjs.map +2 -2
  58. package/build-module/components/block-allowed-blocks/modal.mjs +2 -2
  59. package/build-module/components/block-allowed-blocks/modal.mjs.map +2 -2
  60. package/build-module/components/block-inspector/index.mjs +10 -9
  61. package/build-module/components/block-inspector/index.mjs.map +2 -2
  62. package/build-module/components/block-removal-warning-modal/index.mjs +34 -7
  63. package/build-module/components/block-removal-warning-modal/index.mjs.map +2 -2
  64. package/build-module/components/block-visibility/use-block-visibility.mjs +14 -29
  65. package/build-module/components/block-visibility/use-block-visibility.mjs.map +2 -2
  66. package/build-module/components/global-styles/hooks.mjs +7 -0
  67. package/build-module/components/global-styles/hooks.mjs.map +2 -2
  68. package/build-module/components/global-styles/typography-panel.mjs +73 -4
  69. package/build-module/components/global-styles/typography-panel.mjs.map +2 -2
  70. package/build-module/components/grid/grid-visualizer.mjs +50 -14
  71. package/build-module/components/grid/grid-visualizer.mjs.map +2 -2
  72. package/build-module/components/iframe/index.mjs +9 -2
  73. package/build-module/components/iframe/index.mjs.map +2 -2
  74. package/build-module/components/iframe/use-scale-canvas.mjs +1 -0
  75. package/build-module/components/iframe/use-scale-canvas.mjs.map +2 -2
  76. package/build-module/components/inspector-controls/last-item.mjs +23 -0
  77. package/build-module/components/inspector-controls/last-item.mjs.map +7 -0
  78. package/build-module/components/inspector-controls-tabs/styles-tab.mjs +3 -3
  79. package/build-module/components/inspector-controls-tabs/styles-tab.mjs.map +2 -2
  80. package/build-module/components/link-control/index.mjs +74 -3
  81. package/build-module/components/link-control/index.mjs.map +2 -2
  82. package/build-module/components/link-control/is-url-like.mjs +10 -3
  83. package/build-module/components/link-control/is-url-like.mjs.map +2 -2
  84. package/build-module/components/link-control/search-input.mjs +4 -1
  85. package/build-module/components/link-control/search-input.mjs.map +2 -2
  86. package/build-module/components/link-control/use-search-handler.mjs +2 -2
  87. package/build-module/components/link-control/use-search-handler.mjs.map +2 -2
  88. package/build-module/components/provider/use-block-sync.mjs +60 -8
  89. package/build-module/components/provider/use-block-sync.mjs.map +2 -2
  90. package/build-module/components/text-indent-control/index.mjs +110 -0
  91. package/build-module/components/text-indent-control/index.mjs.map +7 -0
  92. package/build-module/components/url-input/index.mjs +24 -4
  93. package/build-module/components/url-input/index.mjs.map +2 -2
  94. package/build-module/components/url-popover/image-url-input-ui.mjs +2 -2
  95. package/build-module/components/url-popover/image-url-input-ui.mjs.map +2 -2
  96. package/build-module/components/writing-flow/use-arrow-nav.mjs +0 -3
  97. package/build-module/components/writing-flow/use-arrow-nav.mjs.map +2 -2
  98. package/build-module/hooks/anchor.mjs +1 -1
  99. package/build-module/hooks/anchor.mjs.map +1 -1
  100. package/build-module/hooks/aria-label.mjs +2 -1
  101. package/build-module/hooks/aria-label.mjs.map +2 -2
  102. package/build-module/hooks/grid-visualizer.mjs +37 -6
  103. package/build-module/hooks/grid-visualizer.mjs.map +2 -2
  104. package/build-module/hooks/layout-child.mjs +37 -6
  105. package/build-module/hooks/layout-child.mjs.map +2 -2
  106. package/build-module/hooks/typography.mjs +2 -0
  107. package/build-module/hooks/typography.mjs.map +2 -2
  108. package/build-module/hooks/utils.mjs +4 -0
  109. package/build-module/hooks/utils.mjs.map +2 -2
  110. package/build-module/private-apis.mjs +2 -0
  111. package/build-module/private-apis.mjs.map +2 -2
  112. package/build-module/store/actions.mjs +2 -2
  113. package/build-module/store/actions.mjs.map +2 -2
  114. package/package.json +39 -39
  115. package/src/components/block-allowed-blocks/modal.js +2 -2
  116. package/src/components/block-inspector/index.js +19 -17
  117. package/src/components/block-removal-warning-modal/index.js +55 -19
  118. package/src/components/block-switcher/block-transformations-menu.native.js +1 -0
  119. package/src/components/block-toolbar/test/__snapshots__/block-toolbar-menu.native.js.snap +4 -6
  120. package/src/components/block-toolbar/test/block-toolbar-menu.native.js +2 -2
  121. package/src/components/block-visibility/use-block-visibility.js +17 -32
  122. package/src/components/global-styles/hooks.js +10 -0
  123. package/src/components/global-styles/typography-panel.js +78 -1
  124. package/src/components/grid/grid-visualizer.js +58 -12
  125. package/src/components/iframe/index.js +12 -2
  126. package/src/components/iframe/use-scale-canvas.js +1 -0
  127. package/src/components/inserter/menu.native.js +1 -0
  128. package/src/components/inspector-controls/last-item.js +29 -0
  129. package/src/components/inspector-controls-tabs/styles-tab.js +3 -3
  130. package/src/components/link-control/index.js +160 -3
  131. package/src/components/link-control/is-url-like.js +43 -8
  132. package/src/components/link-control/search-input.js +7 -0
  133. package/src/components/link-control/test/index.js +260 -0
  134. package/src/components/link-control/test/is-url-like.js +49 -1
  135. package/src/components/link-control/use-search-handler.js +2 -2
  136. package/src/components/provider/test/use-block-sync.js +105 -0
  137. package/src/components/provider/use-block-sync.js +118 -9
  138. package/src/components/text-indent-control/index.js +138 -0
  139. package/src/components/url-input/index.js +21 -2
  140. package/src/components/url-popover/image-url-input-ui.js +2 -2
  141. package/src/components/writing-flow/use-arrow-nav.js +0 -4
  142. package/src/hooks/anchor.js +1 -1
  143. package/src/hooks/aria-label.js +9 -1
  144. package/src/hooks/grid-visualizer.js +63 -24
  145. package/src/hooks/layout-child.js +45 -3
  146. package/src/hooks/typography.js +2 -0
  147. package/src/hooks/utils.js +4 -0
  148. package/src/private-apis.js +2 -0
  149. package/src/store/actions.js +8 -6
@@ -0,0 +1,29 @@
1
+ /**
2
+ * WordPress dependencies
3
+ */
4
+ import { createSlotFill } from '@wordpress/components';
5
+
6
+ /**
7
+ * Internal dependencies
8
+ */
9
+ import {
10
+ useBlockEditContext,
11
+ mayDisplayControlsKey,
12
+ } from '../block-edit/context';
13
+
14
+ const { Fill, Slot } = createSlotFill( Symbol( 'InspectorControlsLastItem' ) );
15
+
16
+ const InspectorControlsLastItem = ( props ) => {
17
+ const context = useBlockEditContext();
18
+ if ( ! context[ mayDisplayControlsKey ] ) {
19
+ return null;
20
+ }
21
+ return <Fill { ...props } />;
22
+ };
23
+ InspectorControlsLastItem.Slot = function InspectorControlsLastItemSlot(
24
+ props
25
+ ) {
26
+ return <Slot { ...props } />;
27
+ };
28
+
29
+ export default InspectorControlsLastItem;
@@ -23,14 +23,14 @@ function SectionBlockColorControls( {
23
23
  const settings = useBlockSettings( blockName );
24
24
  const { updateBlockAttributes } = useDispatch( blockEditorStore );
25
25
 
26
- const { hasButton, hasHeading } = useSelect(
26
+ const { hasButtons, hasHeading } = useSelect(
27
27
  ( select ) => {
28
28
  const blockNames =
29
29
  select( blockEditorStore ).getBlockNamesByClientId(
30
30
  contentClientIds
31
31
  );
32
32
  return {
33
- hasButton: blockNames.includes( 'core/button' ),
33
+ hasButtons: blockNames.includes( 'core/buttons' ),
34
34
  hasHeading: blockNames.includes( 'core/heading' ),
35
35
  };
36
36
  },
@@ -52,7 +52,7 @@ function SectionBlockColorControls( {
52
52
  defaultControls={ {
53
53
  text: true,
54
54
  background: true,
55
- button: hasButton,
55
+ button: hasButtons,
56
56
  heading: hasHeading,
57
57
  } }
58
58
  />
@@ -24,6 +24,7 @@ import { useSelect, useDispatch } from '@wordpress/data';
24
24
  import { store as preferencesStore } from '@wordpress/preferences';
25
25
  import { keyboardReturn, linkOff } from '@wordpress/icons';
26
26
  import deprecated from '@wordpress/deprecated';
27
+ import { isURL, prependHTTPS } from '@wordpress/url';
27
28
 
28
29
  /**
29
30
  * Internal dependencies
@@ -35,7 +36,8 @@ import LinkSettings from './settings';
35
36
  import useCreatePage from './use-create-page';
36
37
  import useInternalValue from './use-internal-value';
37
38
  import { ViewerFill } from './viewer-slot';
38
- import { DEFAULT_LINK_SETTINGS } from './constants';
39
+ import { DEFAULT_LINK_SETTINGS, LINK_ENTRY_TYPES } from './constants';
40
+ import isURLLike, { isHashLink, isRelativePath } from './is-url-like';
39
41
 
40
42
  /**
41
43
  * Default properties associated with a link control value.
@@ -149,6 +151,10 @@ function LinkControl( {
149
151
  }
150
152
 
151
153
  const [ settingsOpen, setSettingsOpen ] = useState( false );
154
+ // Sets if the URL value is valid when submitted. The value could be set to
155
+ // { type: 'invalid', message: 'Please enter a valid URL.' } or { type: 'valid' }.
156
+ // When it is undefined, the URL value has not been validated.
157
+ const [ customValidity, setCustomValidity ] = useState( undefined );
152
158
 
153
159
  const { advancedSettingsPreference } = useSelect( ( select ) => {
154
160
  const prefsStore = select( preferencesStore );
@@ -264,6 +270,21 @@ function LinkControl( {
264
270
  };
265
271
  }, [] );
266
272
 
273
+ // Trigger validation display when customValidity becomes invalid.
274
+ // This effect runs after React has applied the customValidity state update
275
+ // and ControlWithError's useEffect has set the native validity on the input.
276
+ useEffect( () => {
277
+ if ( customValidity?.type === 'invalid' ) {
278
+ const inputElement = searchInputRef.current;
279
+ if (
280
+ inputElement &&
281
+ typeof inputElement.reportValidity === 'function'
282
+ ) {
283
+ inputElement.reportValidity();
284
+ }
285
+ }
286
+ }, [ customValidity ] );
287
+
267
288
  const hasLinkValue = value?.url?.trim()?.length > 0;
268
289
 
269
290
  /**
@@ -273,7 +294,77 @@ function LinkControl( {
273
294
  setIsEditingLink( false );
274
295
  };
275
296
 
297
+ /**
298
+ * Validates a URL string using a multi-stage validation process.
299
+ * This helper consolidates URL validation logic used throughout the component.
300
+ *
301
+ * @param {string} urlToValidate - The URL string to validate
302
+ * @return {Object} Validation result with isValid boolean and optional errorMessage
303
+ */
304
+ const validateUrl = ( urlToValidate ) => {
305
+ const invalidResult = {
306
+ type: 'invalid',
307
+ message: __( 'Please enter a valid URL.' ),
308
+ };
309
+
310
+ const validResult = {
311
+ type: 'valid',
312
+ };
313
+
314
+ const trimmedValue = urlToValidate?.trim();
315
+
316
+ // If empty or not URL-like, return invalid
317
+ if ( ! trimmedValue?.length || ! isURLLike( trimmedValue ) ) {
318
+ return invalidResult;
319
+ }
320
+
321
+ // Hash links (internal anchor links) and relative paths (/, ./, ../) are
322
+ // valid href values but cannot be validated by the native URL constructor
323
+ // (which requires absolute URLs). These are already validated by isURLLike.
324
+ // Skip URL constructor validation for these cases.
325
+ if ( isHashLink( trimmedValue ) || isRelativePath( trimmedValue ) ) {
326
+ return validResult;
327
+ }
328
+
329
+ // Perform URL validation using the native URL constructor as the authoritative source.
330
+ // The native URL constructor is the standard for URL validity - if it accepts a URL,
331
+ // we should allow it. For URLs without a protocol (e.g., "www.wordpress.org"),
332
+ // prepend "http://" before validating, as the URL constructor requires a protocol.
333
+ //
334
+ // Note: Protocol URLs (mailto:, tel:, etc.) are also validated by the native
335
+ // URL constructor, so we don't need special handling for them.
336
+ //
337
+ // Note: We rely on the native URL constructor rather than implementing custom TLD
338
+ // validation to avoid blocking valid URLs. If a URL passes the native constructor,
339
+ // it's technically valid according to web standards.
340
+ const urlToCheck = prependHTTPS( trimmedValue );
341
+ return isURL( urlToCheck ) ? validResult : invalidResult;
342
+ };
343
+
276
344
  const handleSelectSuggestion = ( updatedValue ) => {
345
+ // Validate URL suggestions (link, mailto, tel, internal) or manually entered URLs.
346
+ // Entity suggestions (post, page, category, etc.) don't need validation as they come from the database.
347
+ // However, URL suggestions (created from user input with types like 'link', 'mailto', etc.)
348
+ // still need validation as they may contain invalid URLs like "www.wordp".
349
+ const isEntitySuggestion =
350
+ updatedValue &&
351
+ updatedValue.id &&
352
+ updatedValue.type &&
353
+ ! LINK_ENTRY_TYPES.includes( updatedValue.type );
354
+
355
+ if ( ! isEntitySuggestion ) {
356
+ // URL suggestion (link, mailto, tel, internal) or manually entered URL - validate before submitting
357
+ // Use the URL from the suggestion, or fall back to currentUrlInputValue
358
+ const urlToValidate = updatedValue?.url || currentUrlInputValue;
359
+
360
+ // Validate the URL using the shared validation helper
361
+ const validation = validateUrl( urlToValidate );
362
+ if ( validation.type === 'invalid' ) {
363
+ setCustomValidity( validation );
364
+ return;
365
+ }
366
+ }
367
+
277
368
  // Preserve the URL for taxonomy entities before binding overrides it
278
369
  if ( updatedValue?.kind === 'taxonomy' && updatedValue?.url ) {
279
370
  entityUrlFallbackRef.current = updatedValue.url;
@@ -301,10 +392,52 @@ function LinkControl( {
301
392
  title: internalControlValue?.title || updatedValue?.title,
302
393
  } );
303
394
 
395
+ // Reset validation state when a suggestion is selected
396
+ setCustomValidity( undefined );
397
+
304
398
  stopEditing();
305
399
  };
306
400
 
307
- const handleSubmit = () => {
401
+ // Centralized validation function
402
+ const validateAndSetValidity = () => {
403
+ if ( currentInputIsEmpty ) {
404
+ return false;
405
+ }
406
+
407
+ const trimmedValue = currentUrlInputValue.trim();
408
+
409
+ // If the current value is an entity link (has id and type not in LINK_ENTRY_TYPES)
410
+ // and the URL hasn't changed from the original value, skip validation.
411
+ // This allows entity links with permalink formats like "?p=2" to work without
412
+ // requiring URL validation when only settings are being changed.
413
+ const isEntityLink =
414
+ internalControlValue &&
415
+ internalControlValue.id &&
416
+ internalControlValue.type &&
417
+ ! LINK_ENTRY_TYPES.includes( internalControlValue.type );
418
+ const urlUnchanged = value?.url === trimmedValue;
419
+
420
+ if ( isEntityLink && urlUnchanged ) {
421
+ // Entity link with unchanged URL - skip validation
422
+ setCustomValidity( undefined );
423
+ return true;
424
+ }
425
+
426
+ // Validate the URL using the shared validation helper
427
+ const validation = validateUrl( currentUrlInputValue );
428
+
429
+ if ( validation.type === 'invalid' ) {
430
+ setCustomValidity( validation );
431
+ return false;
432
+ }
433
+
434
+ // Valid URL
435
+ setCustomValidity( undefined );
436
+ return true;
437
+ };
438
+
439
+ // Centralized submission function
440
+ const submitUrlValue = () => {
308
441
  if ( valueHasChanges ) {
309
442
  // Submit the original value with new stored values applied
310
443
  // on top. URL is a special case as it may also be a prop.
@@ -315,6 +448,17 @@ function LinkControl( {
315
448
  } );
316
449
  }
317
450
  stopEditing();
451
+ setCustomValidity( undefined );
452
+ };
453
+
454
+ const handleSubmit = () => {
455
+ // Validate URL before submitting
456
+ if ( ! validateAndSetValidity() ) {
457
+ return;
458
+ }
459
+
460
+ // Validation passed - proceed with submission
461
+ submitUrlValue();
318
462
  };
319
463
 
320
464
  const handleSubmitWithEnter = ( event ) => {
@@ -340,6 +484,9 @@ function LinkControl( {
340
484
  // Ensure that any unsubmitted input changes are reset.
341
485
  resetInternalValues();
342
486
 
487
+ // Reset validation state
488
+ setCustomValidity( undefined );
489
+
343
490
  if ( hasLinkValue ) {
344
491
  // If there is a link then exist editing mode and show preview.
345
492
  stopEditing();
@@ -388,6 +535,12 @@ function LinkControl( {
388
535
 
389
536
  const currentInputIsEmpty = ! currentUrlInputValue?.trim()?.length;
390
537
 
538
+ // Reset validation state when the URL value changes
539
+ useEffect( () => {
540
+ setCustomValidity( undefined );
541
+ }, [ currentUrlInputValue ] );
542
+
543
+ const isUrlValid = ! customValidity;
391
544
  const shownUnlinkControl =
392
545
  onRemove && value && ! isEditingLink && ! isCreatingPage;
393
546
 
@@ -399,7 +552,10 @@ function LinkControl( {
399
552
  const showTextControl = hasLinkValue && hasTextControl;
400
553
 
401
554
  const isEditing = ( isEditingLink || ! value ) && ! isCreatingPage;
402
- const isDisabled = ! valueHasChanges || currentInputIsEmpty;
555
+ // When creating a new link (no existing value), allow submission if input is not empty and URL is valid
556
+ // When editing an existing link, also require that the value has changed
557
+ const isDisabled =
558
+ currentInputIsEmpty || ! isUrlValid || ( value && ! valueHasChanges );
403
559
  const showSettings = !! settings?.length && isEditingLink && hasLinkValue;
404
560
 
405
561
  const previewValue = useMemo( () => {
@@ -472,6 +628,7 @@ function LinkControl( {
472
628
  }
473
629
  hideLabelFromVision={ ! showTextControl }
474
630
  isEntity={ isEntity }
631
+ customValidity={ customValidity }
475
632
  suffix={
476
633
  <SearchSuffixControl
477
634
  isEntity={ isEntity }
@@ -4,11 +4,42 @@
4
4
  import { getProtocol, isValidProtocol, isValidFragment } from '@wordpress/url';
5
5
 
6
6
  /**
7
- * Determines whether a given value could be a URL. Note this does not
8
- * guarantee the value is a URL only that it looks like it might be one. For
9
- * example, just because a string has `www.` in it doesn't make it a URL,
10
- * but it does make it highly likely that it will be so in the context of
11
- * creating a link it makes sense to treat it like one.
7
+ * Checks if a value is a hash/anchor link (e.g., #section).
8
+ *
9
+ * @param {string} val The value to check.
10
+ * @return {boolean} True if the value is a valid hash link.
11
+ */
12
+ export function isHashLink( val ) {
13
+ return val?.startsWith( '#' ) && isValidFragment( val );
14
+ }
15
+
16
+ /**
17
+ * Checks if a value is a relative path (e.g., /page, ./page, ../page).
18
+ *
19
+ * @param {string} val The value to check.
20
+ * @return {boolean} True if the value is a relative path.
21
+ */
22
+ export function isRelativePath( val ) {
23
+ return (
24
+ val?.startsWith( '/' ) ||
25
+ val?.startsWith( './' ) ||
26
+ val?.startsWith( '../' )
27
+ );
28
+ }
29
+
30
+ /**
31
+ * Determines whether a given value could be a URL or valid href value (like
32
+ * relative paths or hash links). Note this does not guarantee the value is a
33
+ * URL only that it looks like something that should be treated as direct entry
34
+ * rather than a search term. For example, just because a string has `www.` in
35
+ * it doesn't make it a URL, but it does make it highly likely that it will be
36
+ * so in the context of creating a link it makes sense to treat it like one.
37
+ *
38
+ * Examples of "URL-like" values:
39
+ * - URLs with protocols: `https://wordpress.org`, `mailto:test@example.com`
40
+ * - Domain-like strings: `www.wordpress.org`, `wordpress.org`
41
+ * - Relative paths: `/handbook`, `./page`, `../parent`
42
+ * - Hash links: `#section`
12
43
  *
13
44
  * @param {string} val the candidate for being URL-like (or not).
14
45
  *
@@ -28,9 +59,13 @@ export default function isURLLike( val ) {
28
59
 
29
60
  const isWWW = val?.startsWith( 'www.' );
30
61
 
31
- const isInternal = val?.startsWith( '#' ) && isValidFragment( val );
32
-
33
- return protocolIsValid || isWWW || isInternal || mayBeTLD;
62
+ return (
63
+ protocolIsValid ||
64
+ isWWW ||
65
+ isHashLink( val ) ||
66
+ mayBeTLD ||
67
+ isRelativePath( val )
68
+ );
34
69
  }
35
70
 
36
71
  /**
@@ -45,6 +45,7 @@ const LinkControlSearchInput = forwardRef(
45
45
  hideLabelFromVision = false,
46
46
  suffix,
47
47
  isEntity = false,
48
+ customValidity: customValidityProp,
48
49
  },
49
50
  ref
50
51
  ) => {
@@ -146,6 +147,12 @@ const LinkControlSearchInput = forwardRef(
146
147
  __experimentalShowInitialSuggestions={
147
148
  showInitialSuggestions
148
149
  }
150
+ customValidity={ customValidityProp }
151
+ // Suppress the "(Required)" indicator that appears when validation
152
+ // is triggered. The field is still required for validation purposes,
153
+ // but we don't want to show the indicator as it looks cluttered
154
+ // in the link control UI.
155
+ markWhenOptional
149
156
  onSubmit={ ( suggestion, event ) => {
150
157
  const hasSuggestion = suggestion || focusedSuggestion;
151
158
 
@@ -1061,6 +1061,31 @@ describe( 'Link submission', () => {
1061
1061
 
1062
1062
  expect( editSubmitButton ).toHaveAttribute( 'aria-disabled', 'false' );
1063
1063
  } );
1064
+
1065
+ it( 'should disable Apply button when URL is cleared', async () => {
1066
+ const user = userEvent.setup();
1067
+ const mockOnChange = jest.fn();
1068
+
1069
+ const existingLink = { url: 'https://example.com', title: 'Example' };
1070
+ render(
1071
+ <LinkControl
1072
+ value={ existingLink }
1073
+ forceIsEditingLink
1074
+ onChange={ mockOnChange }
1075
+ />
1076
+ );
1077
+
1078
+ const searchInput = screen.getByRole( 'combobox' );
1079
+ // Clear the input
1080
+ await user.clear( searchInput );
1081
+
1082
+ // Apply button should be disabled when input is empty
1083
+ const submitButton = screen.getByRole( 'button', { name: 'Apply' } );
1084
+ expect( submitButton ).toHaveAttribute( 'aria-disabled', 'true' );
1085
+
1086
+ // onChange should not be called
1087
+ expect( mockOnChange ).not.toHaveBeenCalled();
1088
+ } );
1064
1089
  } );
1065
1090
 
1066
1091
  describe( 'Default search suggestions', () => {
@@ -3238,6 +3263,241 @@ describe( 'Custom settings rendering', () => {
3238
3263
  } );
3239
3264
  } );
3240
3265
 
3266
+ describe( 'URL validation', () => {
3267
+ const user = userEvent.setup();
3268
+ const mockOnChange = jest.fn();
3269
+
3270
+ beforeEach( () => {
3271
+ mockOnChange.mockClear();
3272
+ } );
3273
+
3274
+ it( 'should prevent submission for invalid URLs', async () => {
3275
+ render(
3276
+ <LinkControl
3277
+ value={ { url: '' } }
3278
+ forceIsEditingLink
3279
+ onChange={ mockOnChange }
3280
+ />
3281
+ );
3282
+
3283
+ const searchInput = screen.getByRole( 'combobox' );
3284
+ // Use a string that is not a valid URL
3285
+ await user.type( searchInput, 'not a url' );
3286
+
3287
+ // Press Enter - this should trigger validation
3288
+ // Since the value doesn't pass isURLLike, it won't create a suggestion,
3289
+ // but if it did, validation would prevent submission
3290
+ triggerEnter( searchInput );
3291
+
3292
+ // For URLs that don't pass isURLLike, no suggestion is created,
3293
+ // so onChange won't be called (which is the expected behavior)
3294
+ expect( mockOnChange ).not.toHaveBeenCalled();
3295
+ } );
3296
+
3297
+ it.each( [
3298
+ {
3299
+ description: 'valid URLs with protocol',
3300
+ url: 'https://wordpress.org',
3301
+ searchPattern: /https:\/\/wordpress\.org/,
3302
+ },
3303
+ {
3304
+ description: 'valid URLs without protocol (without http://)',
3305
+ url: 'www.wordpress.org',
3306
+ searchPattern: /www\.wordpress\.org/,
3307
+ },
3308
+ {
3309
+ description: 'hash links (internal anchor links)',
3310
+ url: '#section',
3311
+ searchPattern: /#section/,
3312
+ },
3313
+ {
3314
+ description: 'relative paths (URLs starting with /)',
3315
+ url: '/handbook',
3316
+ searchPattern: /\/handbook/,
3317
+ },
3318
+ ] )( 'should accept $description', async ( { url, searchPattern } ) => {
3319
+ render(
3320
+ <LinkControl
3321
+ value={ { url: '' } }
3322
+ forceIsEditingLink
3323
+ onChange={ mockOnChange }
3324
+ />
3325
+ );
3326
+
3327
+ const searchInput = screen.getByRole( 'combobox' );
3328
+ await user.type( searchInput, url );
3329
+
3330
+ // Wait for suggestion to appear and become stable
3331
+ await screen.findByRole( 'option', {
3332
+ name: searchPattern,
3333
+ } );
3334
+
3335
+ triggerEnter( searchInput );
3336
+
3337
+ // No validation error - should succeed
3338
+ await waitFor( () => {
3339
+ expect( mockOnChange ).toHaveBeenCalled();
3340
+ } );
3341
+
3342
+ expect( mockOnChange ).toHaveBeenCalledWith(
3343
+ expect.objectContaining( {
3344
+ url,
3345
+ } )
3346
+ );
3347
+ } );
3348
+
3349
+ it( 'should skip validation for entity suggestions (posts, pages, categories)', async () => {
3350
+ const entityLink = {
3351
+ id: 1,
3352
+ title: 'Hello Page',
3353
+ type: 'page',
3354
+ url: '?p=1',
3355
+ };
3356
+
3357
+ render(
3358
+ <LinkControl
3359
+ value={ entityLink }
3360
+ forceIsEditingLink
3361
+ onChange={ mockOnChange }
3362
+ hasTextControl
3363
+ />
3364
+ );
3365
+
3366
+ // Make a change by toggling the "Open in new tab" setting
3367
+ // Entity links with unchanged URLs skip validation
3368
+ const advancedButton = screen.getByRole( 'button', {
3369
+ name: 'Advanced',
3370
+ } );
3371
+ await user.click( advancedButton );
3372
+
3373
+ const newTabToggle = screen.getByRole( 'checkbox', {
3374
+ name: 'Open in new tab',
3375
+ } );
3376
+ await user.click( newTabToggle );
3377
+
3378
+ const submitButton = screen.getByRole( 'button', { name: 'Apply' } );
3379
+ await user.click( submitButton );
3380
+
3381
+ // Should succeed without validation error
3382
+ await waitFor( () => {
3383
+ expect( mockOnChange ).toHaveBeenCalled();
3384
+ } );
3385
+ expect(
3386
+ screen.queryByText( 'Please enter a valid URL.' )
3387
+ ).not.toBeInTheDocument();
3388
+ } );
3389
+
3390
+ it( 'should show validation error when clicking Apply button with invalid URL', async () => {
3391
+ // When editing an existing link, use Apply button
3392
+ const existingLink = { url: 'https://example.com', title: 'Example' };
3393
+ render(
3394
+ <LinkControl
3395
+ value={ existingLink }
3396
+ forceIsEditingLink
3397
+ onChange={ mockOnChange }
3398
+ />
3399
+ );
3400
+
3401
+ const searchInput = screen.getByRole( 'combobox' );
3402
+ await user.clear( searchInput );
3403
+ await user.type( searchInput, 'invalid url' );
3404
+
3405
+ const submitButton = screen.getByRole( 'button', { name: 'Apply' } );
3406
+
3407
+ // Click the button - validation will run and prevent submission
3408
+ await user.click( submitButton );
3409
+
3410
+ // Wait for the next frame where validation error appears
3411
+ await waitFor(
3412
+ () => {
3413
+ expect(
3414
+ screen.getByText( 'Please enter a valid URL.' )
3415
+ ).toBeVisible();
3416
+ },
3417
+ { timeout: 100 }
3418
+ );
3419
+
3420
+ // onChange should not be called because validation prevented submission
3421
+ expect( mockOnChange ).not.toHaveBeenCalled();
3422
+ } );
3423
+
3424
+ it( 'should show validation error when pressing Enter to submit with an invalid URL', async () => {
3425
+ // When editing an existing link, use Apply button
3426
+ const existingLink = { url: 'https://example.com', title: 'Example' };
3427
+ render(
3428
+ <LinkControl
3429
+ value={ existingLink }
3430
+ forceIsEditingLink
3431
+ onChange={ mockOnChange }
3432
+ />
3433
+ );
3434
+
3435
+ const searchInput = screen.getByRole( 'combobox' );
3436
+ await user.clear( searchInput );
3437
+ await user.type( searchInput, 'invalid url' );
3438
+
3439
+ // Click without blur - use fireEvent for synchronous click
3440
+ triggerEnter( searchInput );
3441
+
3442
+ // Wait for the next frame where validation error appears
3443
+ await waitFor(
3444
+ () => {
3445
+ expect(
3446
+ screen.getByText( 'Please enter a valid URL.' )
3447
+ ).toBeVisible();
3448
+ },
3449
+ { timeout: 100 }
3450
+ );
3451
+
3452
+ // onChange should not be called because validation prevented submission
3453
+ expect( mockOnChange ).not.toHaveBeenCalled();
3454
+ } );
3455
+
3456
+ it( 'should allow URLs that pass native URL constructor validation', async () => {
3457
+ render(
3458
+ <LinkControl
3459
+ value={ { url: '' } }
3460
+ forceIsEditingLink
3461
+ onChange={ mockOnChange }
3462
+ />
3463
+ );
3464
+
3465
+ const searchInput = screen.getByRole( 'combobox' );
3466
+ // This URL may seem invalid but passes native URL constructor
3467
+ await user.type( searchInput, 'www.wordpress' );
3468
+
3469
+ // Wait for suggestion to appear and become stable
3470
+ await screen.findByRole( 'option', {
3471
+ name: /www\.wordpress/,
3472
+ } );
3473
+
3474
+ triggerEnter( searchInput );
3475
+
3476
+ // Should be accepted (validation philosophy: native URL constructor is authoritative)
3477
+ await waitFor( () => {
3478
+ expect( mockOnChange ).toHaveBeenCalled();
3479
+ } );
3480
+
3481
+ // This URL passes native URL constructor validation, so we allow it.
3482
+ // While "www.wordpress" (without a TLD like .com or .org) is technically
3483
+ // valid and could resolve (e.g., on an intranet), it's unlikely to be
3484
+ // a useful URL in practice. However, our validation philosophy is to
3485
+ // trust the native URL constructor as the authoritative source - if the
3486
+ // browser accepts it, we accept it.
3487
+ expect( mockOnChange ).toHaveBeenCalledWith(
3488
+ expect.objectContaining( {
3489
+ url: 'www.wordpress',
3490
+ } )
3491
+ );
3492
+ } );
3493
+
3494
+ // Note: mailto: and tel: protocol URLs are handled by the validation logic
3495
+ // (they skip URL constructor validation if they have a valid protocol),
3496
+ // but testing them in the jsdom environment is problematic as the native
3497
+ // URL constructor behavior may differ. These URLs are covered by the
3498
+ // isURLLike validation which checks for valid protocols.
3499
+ } );
3500
+
3241
3501
  function getSettingsDrawerToggle() {
3242
3502
  return screen.queryByRole( 'button', {
3243
3503
  name: 'Advanced',