@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.
- package/CHANGELOG.md +6 -0
- package/build/components/block-allowed-blocks/modal.cjs +1 -1
- package/build/components/block-allowed-blocks/modal.cjs.map +2 -2
- package/build/components/block-inspector/index.cjs +9 -9
- package/build/components/block-inspector/index.cjs.map +3 -3
- package/build/components/block-removal-warning-modal/index.cjs +30 -5
- package/build/components/block-removal-warning-modal/index.cjs.map +3 -3
- package/build/components/block-visibility/use-block-visibility.cjs +14 -29
- package/build/components/block-visibility/use-block-visibility.cjs.map +2 -2
- package/build/components/global-styles/hooks.cjs +7 -0
- package/build/components/global-styles/hooks.cjs.map +2 -2
- package/build/components/global-styles/typography-panel.cjs +71 -3
- package/build/components/global-styles/typography-panel.cjs.map +3 -3
- package/build/components/grid/grid-visualizer.cjs +49 -13
- package/build/components/grid/grid-visualizer.cjs.map +2 -2
- package/build/components/iframe/index.cjs +3 -1
- package/build/components/iframe/index.cjs.map +2 -2
- package/build/components/iframe/use-scale-canvas.cjs +1 -0
- package/build/components/iframe/use-scale-canvas.cjs.map +2 -2
- package/build/components/inspector-controls/last-item.cjs +41 -0
- package/build/components/inspector-controls/last-item.cjs.map +7 -0
- package/build/components/inspector-controls-tabs/styles-tab.cjs +3 -3
- package/build/components/inspector-controls-tabs/styles-tab.cjs.map +2 -2
- package/build/components/link-control/index.cjs +73 -2
- package/build/components/link-control/index.cjs.map +3 -3
- package/build/components/link-control/is-url-like.cjs +15 -3
- package/build/components/link-control/is-url-like.cjs.map +2 -2
- package/build/components/link-control/search-input.cjs +4 -1
- package/build/components/link-control/search-input.cjs.map +2 -2
- package/build/components/link-control/use-search-handler.cjs +1 -1
- package/build/components/link-control/use-search-handler.cjs.map +2 -2
- package/build/components/provider/use-block-sync.cjs +60 -8
- package/build/components/provider/use-block-sync.cjs.map +2 -2
- package/build/components/text-indent-control/index.cjs +121 -0
- package/build/components/text-indent-control/index.cjs.map +7 -0
- package/build/components/url-input/index.cjs +22 -2
- package/build/components/url-input/index.cjs.map +3 -3
- package/build/components/url-popover/image-url-input-ui.cjs +1 -1
- package/build/components/url-popover/image-url-input-ui.cjs.map +2 -2
- package/build/components/writing-flow/use-arrow-nav.cjs +0 -3
- package/build/components/writing-flow/use-arrow-nav.cjs.map +2 -2
- package/build/hooks/anchor.cjs +1 -1
- package/build/hooks/anchor.cjs.map +1 -1
- package/build/hooks/aria-label.cjs +2 -1
- package/build/hooks/aria-label.cjs.map +2 -2
- package/build/hooks/grid-visualizer.cjs +59 -6
- package/build/hooks/grid-visualizer.cjs.map +3 -3
- package/build/hooks/layout-child.cjs +47 -6
- package/build/hooks/layout-child.cjs.map +3 -3
- package/build/hooks/typography.cjs +2 -0
- package/build/hooks/typography.cjs.map +2 -2
- package/build/hooks/utils.cjs +4 -0
- package/build/hooks/utils.cjs.map +2 -2
- package/build/private-apis.cjs +2 -0
- package/build/private-apis.cjs.map +3 -3
- package/build/store/actions.cjs +2 -2
- package/build/store/actions.cjs.map +2 -2
- package/build-module/components/block-allowed-blocks/modal.mjs +2 -2
- package/build-module/components/block-allowed-blocks/modal.mjs.map +2 -2
- package/build-module/components/block-inspector/index.mjs +10 -9
- package/build-module/components/block-inspector/index.mjs.map +2 -2
- package/build-module/components/block-removal-warning-modal/index.mjs +34 -7
- package/build-module/components/block-removal-warning-modal/index.mjs.map +2 -2
- package/build-module/components/block-visibility/use-block-visibility.mjs +14 -29
- package/build-module/components/block-visibility/use-block-visibility.mjs.map +2 -2
- package/build-module/components/global-styles/hooks.mjs +7 -0
- package/build-module/components/global-styles/hooks.mjs.map +2 -2
- package/build-module/components/global-styles/typography-panel.mjs +73 -4
- package/build-module/components/global-styles/typography-panel.mjs.map +2 -2
- package/build-module/components/grid/grid-visualizer.mjs +50 -14
- package/build-module/components/grid/grid-visualizer.mjs.map +2 -2
- package/build-module/components/iframe/index.mjs +9 -2
- package/build-module/components/iframe/index.mjs.map +2 -2
- package/build-module/components/iframe/use-scale-canvas.mjs +1 -0
- package/build-module/components/iframe/use-scale-canvas.mjs.map +2 -2
- package/build-module/components/inspector-controls/last-item.mjs +23 -0
- package/build-module/components/inspector-controls/last-item.mjs.map +7 -0
- package/build-module/components/inspector-controls-tabs/styles-tab.mjs +3 -3
- package/build-module/components/inspector-controls-tabs/styles-tab.mjs.map +2 -2
- package/build-module/components/link-control/index.mjs +74 -3
- package/build-module/components/link-control/index.mjs.map +2 -2
- package/build-module/components/link-control/is-url-like.mjs +10 -3
- package/build-module/components/link-control/is-url-like.mjs.map +2 -2
- package/build-module/components/link-control/search-input.mjs +4 -1
- package/build-module/components/link-control/search-input.mjs.map +2 -2
- package/build-module/components/link-control/use-search-handler.mjs +2 -2
- package/build-module/components/link-control/use-search-handler.mjs.map +2 -2
- package/build-module/components/provider/use-block-sync.mjs +60 -8
- package/build-module/components/provider/use-block-sync.mjs.map +2 -2
- package/build-module/components/text-indent-control/index.mjs +110 -0
- package/build-module/components/text-indent-control/index.mjs.map +7 -0
- package/build-module/components/url-input/index.mjs +24 -4
- package/build-module/components/url-input/index.mjs.map +2 -2
- package/build-module/components/url-popover/image-url-input-ui.mjs +2 -2
- package/build-module/components/url-popover/image-url-input-ui.mjs.map +2 -2
- package/build-module/components/writing-flow/use-arrow-nav.mjs +0 -3
- package/build-module/components/writing-flow/use-arrow-nav.mjs.map +2 -2
- package/build-module/hooks/anchor.mjs +1 -1
- package/build-module/hooks/anchor.mjs.map +1 -1
- package/build-module/hooks/aria-label.mjs +2 -1
- package/build-module/hooks/aria-label.mjs.map +2 -2
- package/build-module/hooks/grid-visualizer.mjs +37 -6
- package/build-module/hooks/grid-visualizer.mjs.map +2 -2
- package/build-module/hooks/layout-child.mjs +37 -6
- package/build-module/hooks/layout-child.mjs.map +2 -2
- package/build-module/hooks/typography.mjs +2 -0
- package/build-module/hooks/typography.mjs.map +2 -2
- package/build-module/hooks/utils.mjs +4 -0
- package/build-module/hooks/utils.mjs.map +2 -2
- package/build-module/private-apis.mjs +2 -0
- package/build-module/private-apis.mjs.map +2 -2
- package/build-module/store/actions.mjs +2 -2
- package/build-module/store/actions.mjs.map +2 -2
- package/package.json +39 -39
- package/src/components/block-allowed-blocks/modal.js +2 -2
- package/src/components/block-inspector/index.js +19 -17
- package/src/components/block-removal-warning-modal/index.js +55 -19
- package/src/components/block-switcher/block-transformations-menu.native.js +1 -0
- package/src/components/block-toolbar/test/__snapshots__/block-toolbar-menu.native.js.snap +4 -6
- package/src/components/block-toolbar/test/block-toolbar-menu.native.js +2 -2
- package/src/components/block-visibility/use-block-visibility.js +17 -32
- package/src/components/global-styles/hooks.js +10 -0
- package/src/components/global-styles/typography-panel.js +78 -1
- package/src/components/grid/grid-visualizer.js +58 -12
- package/src/components/iframe/index.js +12 -2
- package/src/components/iframe/use-scale-canvas.js +1 -0
- package/src/components/inserter/menu.native.js +1 -0
- package/src/components/inspector-controls/last-item.js +29 -0
- package/src/components/inspector-controls-tabs/styles-tab.js +3 -3
- package/src/components/link-control/index.js +160 -3
- package/src/components/link-control/is-url-like.js +43 -8
- package/src/components/link-control/search-input.js +7 -0
- package/src/components/link-control/test/index.js +260 -0
- package/src/components/link-control/test/is-url-like.js +49 -1
- package/src/components/link-control/use-search-handler.js +2 -2
- package/src/components/provider/test/use-block-sync.js +105 -0
- package/src/components/provider/use-block-sync.js +118 -9
- package/src/components/text-indent-control/index.js +138 -0
- package/src/components/url-input/index.js +21 -2
- package/src/components/url-popover/image-url-input-ui.js +2 -2
- package/src/components/writing-flow/use-arrow-nav.js +0 -4
- package/src/hooks/anchor.js +1 -1
- package/src/hooks/aria-label.js +9 -1
- package/src/hooks/grid-visualizer.js +63 -24
- package/src/hooks/layout-child.js +45 -3
- package/src/hooks/typography.js +2 -0
- package/src/hooks/utils.js +4 -0
- package/src/private-apis.js +2 -0
- 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 {
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
|
|
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
|
-
|
|
32
|
-
|
|
33
|
-
|
|
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',
|