diff --git a/packages/block-editor/src/components/link-control/README.md b/packages/block-editor/src/components/link-control/README.md
index 4b590885ae8be5..ad82cae83d2b64 100644
--- a/packages/block-editor/src/components/link-control/README.md
+++ b/packages/block-editor/src/components/link-control/README.md
@@ -25,7 +25,7 @@ Default properties include:
- Type: `Array`
- Required: No
-- Default:
+- Default:
```
[
{
@@ -54,7 +54,7 @@ Value change handler, called with the updated value if the user selects a new li
onChange={ ( nextValue ) => {
console.log( `The selected item URL: ${ nextValue.url }.` );
}
-/>
+/>
```
### showInitialSuggestions
@@ -71,3 +71,60 @@ Whether to present initial suggestions immediately.
- Required: No
If passed as either `true` or `false`, controls the internal editing state of the component to respective show or not show the URL input field.
+
+
+### createSuggestion
+
+- Type: `function`
+- Required: No
+
+Used to handle the dynamic creation of new suggestions within the Link UI. When
+the prop is provided, an option is added to the end of all search
+results requests which when clicked will call `createSuggestion` callback
+(passing the current value of the search ``) in
+order to afford the parent component the opportunity to dynamically create a new
+link `value` (see above).
+
+This is often used to allow on-the-fly creation of new entities (eg: `Posts`,
+`Pages`) based on the text the user has entered into the link search UI. For
+example, the Navigation Block uses this to create Pages on demand.
+
+When called, `createSuggestion` may return either a new suggestion directly or a `Promise` which resolves to a
+new suggestion. Suggestions have the following shape:
+
+```js
+{
+ id: // unique identifier
+ type: // "url", "page", "post"...etc
+ title: // "My new suggestion"
+ url: // any string representing the URL value
+}
+```
+
+#### Example
+```jsx
+// Promise example
+ {
+ // Hard coded values. These could be dynamically created by calling out to an API which creates an entity (eg: https://developer.wordpress.org/rest-api/reference/pages/#create-a-page).
+ return {
+ id: 1234,
+ type: 'page',
+ title: inputText,
+ url: '/some-url-here'
+ }
+ }}
+/>
+
+// Non-Promise example
+ (
+ {
+ id: 1234,
+ type: 'page',
+ title: inputText,
+ url: '/some-url-here'
+ }
+ )}
+/>
+```
\ No newline at end of file
diff --git a/packages/block-editor/src/components/link-control/index.js b/packages/block-editor/src/components/link-control/index.js
index 3ef9c75fa8f261..dcd9c54dbd49d4 100644
--- a/packages/block-editor/src/components/link-control/index.js
+++ b/packages/block-editor/src/components/link-control/index.js
@@ -7,7 +7,12 @@ import { noop, startsWith } from 'lodash';
/**
* WordPress dependencies
*/
-import { Button, ExternalLink, VisuallyHidden } from '@wordpress/components';
+import {
+ Button,
+ ExternalLink,
+ Spinner,
+ VisuallyHidden,
+} from '@wordpress/components';
import { __, sprintf } from '@wordpress/i18n';
import {
useRef,
@@ -15,6 +20,7 @@ import {
useState,
Fragment,
useEffect,
+ createElement,
} from '@wordpress/element';
import {
safeDecodeURI,
@@ -33,7 +39,39 @@ import { focus } from '@wordpress/dom';
import LinkControlSettingsDrawer from './settings-drawer';
import LinkControlSearchItem from './search-item';
import LinkControlSearchInput from './search-input';
+import LinkControlSearchCreate from './search-create-button';
+
+// Used as a unique identifier for the "Create" option within search results.
+// Used to help distinguish the "Create" suggestion within the search results in
+// order to handle it as a unique case.
+const CREATE_TYPE = '__CREATE__';
+
+/**
+ * Creates a wrapper around a promise which allows it to be programmatically
+ * cancelled.
+ * See: https://reactjs.org/blog/2015/12/16/ismounted-antipattern.html
+ *
+ * @param {Promise} promise the Promise to make cancelable
+ */
+const makeCancelable = ( promise ) => {
+ let hasCanceled_ = false;
+
+ const wrappedPromise = new Promise( ( resolve, reject ) => {
+ promise.then(
+ ( val ) =>
+ hasCanceled_ ? reject( { isCanceled: true } ) : resolve( val ),
+ ( error ) =>
+ hasCanceled_ ? reject( { isCanceled: true } ) : reject( error )
+ );
+ } );
+ return {
+ promise: wrappedPromise,
+ cancel() {
+ hasCanceled_ = true;
+ },
+ };
+};
/**
* Default properties associated with a link control value.
*
@@ -70,18 +108,33 @@ import LinkControlSearchInput from './search-input';
/** @typedef {(nextValue:WPLinkControlValue)=>void} WPLinkControlOnChangeProp */
+/**
+ * Properties associated with a search suggestion used within the LinkControl.
+ *
+ * @typedef WPLinkControlSuggestion
+ *
+ * @property {string} id Identifier to use to uniquely identify the suggestion.
+ * @property {string} type Identifies the type of the suggestion (eg: `post`,
+ * `page`, `url`...etc)
+ * @property {string} title Human-readable label to show in user interface.
+ * @property {string} url A URL for the suggestion.
+ */
+
+/** @typedef {(title:string)=>WPLinkControlSuggestion} WPLinkControlCreateSuggestionProp */
+
/**
* @typedef WPLinkControlProps
*
- * @property {(WPLinkControlSetting[])=} settings An array of settings objects. Each object will used to
- * render a `ToggleControl` for that setting.
- * @property {boolean=} forceIsEditingLink If passed as either `true` or `false`, controls the
- * internal editing state of the component to respective
- * show or not show the URL input field.
- * @property {WPLinkControlValue=} value Current link value.
- * @property {WPLinkControlOnChangeProp=} onChange Value change handler, called with the updated value if
- * the user selects a new link or updates settings.
- * @property {boolean=} showInitialSuggestions Whether to present initial suggestions immediately.
+ * @property {(WPLinkControlSetting[])=} settings An array of settings objects. Each object will used to
+ * render a `ToggleControl` for that setting.
+ * @property {boolean=} forceIsEditingLink If passed as either `true` or `false`, controls the
+ * internal editing state of the component to respective
+ * show or not show the URL input field.
+ * @property {WPLinkControlValue=} value Current link value.
+ * @property {WPLinkControlOnChangeProp=} onChange Value change handler, called with the updated value if
+ * the user selects a new link or updates settings.
+ * @property {boolean=} showInitialSuggestions Whether to present initial suggestions immediately.
+ * @property {WPLinkControlCreateSuggestionProp=} createSuggestion Handler to manage creation of link value from suggestion.
*/
/**
@@ -97,7 +150,11 @@ function LinkControl( {
onChange = noop,
showInitialSuggestions,
forceIsEditingLink,
+ createSuggestion,
} ) {
+ const cancelableOnCreate = useRef();
+ const cancelableCreateSuggestion = useRef();
+
const wrapperNode = useRef();
const instanceId = useInstanceId( LinkControl );
const [ inputValue, setInputValue ] = useState(
@@ -108,7 +165,10 @@ function LinkControl( {
? forceIsEditingLink
: ! value || ! value.url
);
+ const [ isResolvingLink, setIsResolvingLink ] = useState( false );
+ const [ errorMessage, setErrorMessage ] = useState( null );
const isEndingEditWithFocus = useRef( false );
+
const { fetchSearchSuggestions } = useSelect( ( select ) => {
const { getSettings } = select( 'core/block-editor' );
return {
@@ -154,6 +214,21 @@ function LinkControl( {
isEndingEditWithFocus.current = false;
}, [ isEditingLink ] );
+ /**
+ * Handles cancelling any pending Promises that have been made cancelable.
+ */
+ useEffect( () => {
+ return () => {
+ // componentDidUnmount
+ if ( cancelableOnCreate.current ) {
+ cancelableOnCreate.current.cancel();
+ }
+ if ( cancelableCreateSuggestion.current ) {
+ cancelableCreateSuggestion.current.cancel();
+ }
+ };
+ }, [] );
+
/**
* onChange LinkControlSearchInput event handler
*
@@ -182,7 +257,7 @@ function LinkControl( {
return Promise.resolve( [
{
- id: '-1',
+ id: val,
title: val,
url: type === 'URL' ? prependHTTP( val ) : val,
type,
@@ -191,7 +266,7 @@ function LinkControl( {
};
const handleEntitySearch = async ( val, args ) => {
- const results = await Promise.all( [
+ let results = await Promise.all( [
fetchSearchSuggestions( val, {
...( args.isInitialSuggestions ? { perPage: 3 } : {} ),
} ),
@@ -203,9 +278,35 @@ function LinkControl( {
// If it's potentially a URL search then concat on a URL search suggestion
// just for good measure. That way once the actual results run out we always
// have a URL option to fallback on.
- return couldBeURL && ! args.isInitialSuggestions
- ? results[ 0 ].concat( results[ 1 ] )
- : results[ 0 ];
+ results =
+ couldBeURL && ! args.isInitialSuggestions
+ ? results[ 0 ].concat( results[ 1 ] )
+ : results[ 0 ];
+
+ // Here we append a faux suggestion to represent a "CREATE" option. This
+ // is detected in the rendering of the search results and handled as a
+ // special case. This is currently necessary because the suggestions
+ // dropdown will only appear if there are valid suggestions and
+ // therefore unless the create option is a suggestion it will not
+ // display in scenarios where there are no results returned from the
+ // API. In addition promoting CREATE to a first class suggestion affords
+ // the a11y benefits afforded by `URLInput` to all suggestions (eg:
+ // keyboard handling, ARIA roles...etc).
+ //
+ // Note also that the value of the `title` and `url` properties must correspond
+ // to the text value of the ``. This is because `title` is used
+ // when creating the suggestion. Similarly `url` is used when using keyboard to select
+ // the suggestion (the