diff --git a/packages/block-editor/src/components/index.js b/packages/block-editor/src/components/index.js
index 6e9bb592378678..56dbc123b2358d 100644
--- a/packages/block-editor/src/components/index.js
+++ b/packages/block-editor/src/components/index.js
@@ -22,6 +22,7 @@ export { default as __experimentalGradientPickerControl } from './gradient-picke
export { default as InnerBlocks } from './inner-blocks';
export { default as InspectorAdvancedControls } from './inspector-advanced-controls';
export { default as InspectorControls } from './inspector-controls';
+export { default as __experimentalLinkControl } from './link-control';
export { default as MediaPlaceholder } from './media-placeholder';
export { default as MediaUpload } from './media-upload';
export { default as MediaUploadCheck } from './media-upload/check';
diff --git a/packages/block-editor/src/components/link-control/README.md b/packages/block-editor/src/components/link-control/README.md
new file mode 100644
index 00000000000000..e21c29974301b1
--- /dev/null
+++ b/packages/block-editor/src/components/link-control/README.md
@@ -0,0 +1,50 @@
+# Link Control
+
+## Props
+
+### className
+
+- Type: `String`
+- Required: Yes
+
+### currentLink
+
+- Type: `Object`
+- Required: Yes
+
+### currentSettings
+
+- Type: `Object`
+- Required: Yes
+
+### fetchSearchSuggestions
+
+- Type: `Function`
+- Required: No
+
+## Event handlers
+
+### onClose
+
+- Type: `Function`
+- Required: No
+
+### onKeyDown
+
+- Type: `Function`
+- Required: No
+
+### onKeyPress
+
+- Type: `Function`
+- Required: No
+
+### onLinkChange
+
+- Type: `Function`
+- Required: No
+
+### onSettingChange
+
+- Type: `Function`
+- Required: No
diff --git a/packages/block-editor/src/components/link-control/index.js b/packages/block-editor/src/components/link-control/index.js
new file mode 100644
index 00000000000000..afe72df4f2e93d
--- /dev/null
+++ b/packages/block-editor/src/components/link-control/index.js
@@ -0,0 +1,249 @@
+/**
+ * External dependencies
+ */
+import classnames from 'classnames';
+import { isFunction, noop, startsWith } from 'lodash';
+
+/**
+ * WordPress dependencies
+ */
+import {
+ Button,
+ ExternalLink,
+ Popover,
+} from '@wordpress/components';
+import { __ } from '@wordpress/i18n';
+
+import {
+ useCallback,
+ useState,
+ useEffect,
+ Fragment,
+} from '@wordpress/element';
+
+import {
+ safeDecodeURI,
+ filterURLForDisplay,
+ isURL,
+ prependHTTP,
+ getProtocol,
+} from '@wordpress/url';
+
+import { withInstanceId, compose } from '@wordpress/compose';
+import { withSelect } from '@wordpress/data';
+
+/**
+ * Internal dependencies
+ */
+import LinkControlSettingsDrawer from './settings-drawer';
+import LinkControlSearchItem from './search-item';
+import LinkControlSearchInput from './search-input';
+
+function LinkControl( {
+ className,
+ currentLink,
+ currentSettings,
+ fetchSearchSuggestions,
+ instanceId,
+ onClose = noop,
+ onKeyDown = noop,
+ onKeyPress = noop,
+ onLinkChange = noop,
+ onSettingsChange = { noop },
+} ) {
+ // State
+ const [ inputValue, setInputValue ] = useState( '' );
+ const [ isEditingLink, setIsEditingLink ] = useState( false );
+
+ // Effects
+ useEffect( () => {
+ // If we have a link then stop editing mode
+ if ( currentLink ) {
+ setIsEditingLink( false );
+ } else {
+ setIsEditingLink( true );
+ }
+ }, [ currentLink ] );
+
+ // Handlers
+
+ /**
+ * onChange LinkControlSearchInput event handler
+ *
+ * @param {string} value Current value returned by the search.
+ */
+ const onInputChange = ( value = '' ) => {
+ setInputValue( value );
+ };
+
+ // Utils
+ const startEditMode = () => {
+ if ( isFunction( onLinkChange ) ) {
+ onLinkChange();
+ }
+ };
+
+ const closeLinkUI = () => {
+ resetInput();
+ onClose();
+ };
+
+ const resetInput = () => {
+ setInputValue( '' );
+ };
+
+ const handleDirectEntry = ( value ) => {
+ let type = 'URL';
+
+ const protocol = getProtocol( value ) || '';
+
+ if ( protocol.includes( 'mailto' ) ) {
+ type = 'mailto';
+ }
+
+ if ( protocol.includes( 'tel' ) ) {
+ type = 'tel';
+ }
+
+ if ( startsWith( value, '#' ) ) {
+ type = 'internal';
+ }
+
+ return Promise.resolve(
+ [ {
+ id: '-1',
+ title: value,
+ url: type === 'URL' ? prependHTTP( value ) : value,
+ type,
+ } ]
+ );
+ };
+
+ const handleEntitySearch = async ( value ) => {
+ const results = await Promise.all( [
+ fetchSearchSuggestions( value ),
+ handleDirectEntry( value ),
+ ] );
+
+ const couldBeURL = ! value.includes( ' ' );
+
+ // 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 ? results[ 0 ].concat( results[ 1 ] ) : results[ 0 ];
+ };
+
+ // Effects
+ const getSearchHandler = useCallback( ( value ) => {
+ const protocol = getProtocol( value ) || '';
+ const isMailto = protocol.includes( 'mailto' );
+ const isInternal = startsWith( value, '#' );
+ const isTel = protocol.includes( 'tel' );
+
+ const handleManualEntry = isInternal || isMailto || isTel || isURL( value ) || ( value && value.includes( 'www.' ) );
+
+ return ( handleManualEntry ) ? handleDirectEntry( value ) : handleEntitySearch( value );
+ }, [ handleDirectEntry, fetchSearchSuggestions ] );
+
+ // Render Components
+ const renderSearchResults = ( { suggestionsListProps, buildSuggestionItemProps, suggestions, selectedSuggestion, isLoading } ) => {
+ const resultsListClasses = classnames( 'block-editor-link-control__search-results', {
+ 'is-loading': isLoading,
+ } );
+
+ const manualLinkEntryTypes = [ 'url', 'mailto', 'tel', 'internal' ];
+
+ return (
+
+
+ { suggestions.map( ( suggestion, index ) => (
+ onLinkChange( suggestion ) }
+ isSelected={ index === selectedSuggestion }
+ isURL={ manualLinkEntryTypes.includes( suggestion.type.toLowerCase() ) }
+ searchTerm={ inputValue }
+ />
+ ) ) }
+
+
+ );
+ };
+
+ return (
+
+
+
+
+ { ( ! isEditingLink && currentLink ) && (
+
+
+ { __( 'Currently selected' ) }:
+
+
+
+
+
+ { currentLink.title }
+
+ { filterURLForDisplay( safeDecodeURI( currentLink.url ) ) || '' }
+
+
+
+
+
+ ) }
+
+ { isEditingLink && (
+
+ ) }
+
+ { ! isEditingLink && (
+
+ ) }
+
+
+
+ );
+}
+
+export default compose(
+ withInstanceId,
+ withSelect( ( select, ownProps ) => {
+ if ( ownProps.fetchSearchSuggestions && isFunction( ownProps.fetchSearchSuggestions ) ) {
+ return;
+ }
+
+ const { getSettings } = select( 'core/block-editor' );
+ return {
+ fetchSearchSuggestions: getSettings().__experimentalFetchLinkSuggestions,
+ };
+ } )
+)( LinkControl );
diff --git a/packages/block-editor/src/components/link-control/search-input.js b/packages/block-editor/src/components/link-control/search-input.js
new file mode 100644
index 00000000000000..84fd5db8359363
--- /dev/null
+++ b/packages/block-editor/src/components/link-control/search-input.js
@@ -0,0 +1,69 @@
+
+/**
+ * WordPress dependencies
+ */
+import { __ } from '@wordpress/i18n';
+import { IconButton } from '@wordpress/components';
+import { ENTER } from '@wordpress/keycodes';
+
+/**
+ * Internal dependencies
+ */
+import { URLInput } from '../';
+
+const LinkControlSearchInput = ( {
+ value,
+ onChange,
+ onSelect,
+ renderSuggestions,
+ fetchSuggestions,
+ onReset,
+ onKeyDown,
+ onKeyPress,
+} ) => {
+ const selectItemHandler = ( selection, suggestion ) => {
+ onChange( selection );
+
+ if ( suggestion ) {
+ onSelect( suggestion );
+ }
+ };
+
+ const stopFormEventsPropagation = ( event ) => {
+ event.preventDefault();
+ event.stopPropagation();
+ };
+
+ return (
+
+ );
+};
+
+export default LinkControlSearchInput;
diff --git a/packages/block-editor/src/components/link-control/search-item.js b/packages/block-editor/src/components/link-control/search-item.js
new file mode 100644
index 00000000000000..432b4bb3dff17a
--- /dev/null
+++ b/packages/block-editor/src/components/link-control/search-item.js
@@ -0,0 +1,54 @@
+/**
+ * External dependencies
+ */
+import classnames from 'classnames';
+
+/**
+ * Internal dependencies
+ */
+import TextHighlight from './text-highlight';
+
+/**
+ * WordPress dependencies
+ */
+import { safeDecodeURI } from '@wordpress/url';
+import { __ } from '@wordpress/i18n';
+
+import {
+ Icon,
+} from '@wordpress/components';
+
+export const LinkControlSearchItem = ( { itemProps, suggestion, isSelected = false, onClick, isURL = false, searchTerm = '' } ) => {
+ return (
+
+ );
+};
+
+export default LinkControlSearchItem;
+
diff --git a/packages/block-editor/src/components/link-control/settings-drawer.js b/packages/block-editor/src/components/link-control/settings-drawer.js
new file mode 100644
index 00000000000000..372426e4e821ff
--- /dev/null
+++ b/packages/block-editor/src/components/link-control/settings-drawer.js
@@ -0,0 +1,29 @@
+/**
+ * External dependencies
+ */
+import { partial } from 'lodash';
+
+/**
+ * WordPress dependencies
+ */
+import { __ } from '@wordpress/i18n';
+import {
+ ToggleControl,
+} from '@wordpress/components';
+
+const LinkControlSettingsDrawer = ( { settings, onSettingChange } ) => {
+ if ( ! settings || settings.length ) {
+ return null;
+ }
+
+ return (
+
+
+
+ );
+};
+
+export default LinkControlSettingsDrawer;
diff --git a/packages/block-editor/src/components/link-control/style.scss b/packages/block-editor/src/components/link-control/style.scss
new file mode 100644
index 00000000000000..69f87d79fdfe3d
--- /dev/null
+++ b/packages/block-editor/src/components/link-control/style.scss
@@ -0,0 +1,202 @@
+.block-editor-link-control__search {
+ position: relative;
+ min-width: $modal-min-width;
+}
+
+.block-editor-link-control__search .block-editor-link-control__search-input {
+ // Specificity overide
+ &.block-editor-link-control__search-input > input[type="text"] {
+ width: calc(100% - #{$grid-size-large*2});
+ display: block;
+ padding: 11px $grid-size-large;
+ margin: $grid-size-large;
+ padding-right: 38px; // width of reset button
+ position: relative;
+ z-index: 1;
+ border: 1px solid #e1e1e1;
+ border-radius: $radius-round-rectangle;
+
+ /* Fonts smaller than 16px causes mobile safari to zoom. */
+ font-size: $mobile-text-min-font-size;
+
+ @include break-small {
+ font-size: $default-font-size;
+ }
+
+ &:focus {
+ @include input-style__focus();
+ }
+ }
+}
+
+.block-editor-link-control__search-reset {
+ position: absolute;
+ top: 19px; // has to be hard coded as form expands with search suggestions
+ right: 19px; // push away to avoid focus style obscuring input border
+ z-index: 10;
+}
+
+.block-editor-link-control__search-results-wrapper {
+ position: relative;
+ margin-top: -$grid-size-large + 1px;
+
+ &::before,
+ &::after {
+ content: "";
+ position: absolute;
+ left: -1px;
+ right: $grid-size-large; // avoid overlaying scrollbars
+ display: block;
+ pointer-events: none;
+ z-index: 100;
+ }
+
+ &::before {
+ height: $grid-size-large/2;
+ top: -1px;
+ bottom: auto;
+ background: linear-gradient(to bottom, rgba(255, 255, 255, 1) 0%, rgba(255, 255, 255, 0) 100%);
+ }
+
+ &::after {
+ height: 20px;
+ bottom: -1px;
+ top: auto;
+ background: linear-gradient(to bottom, rgba(255, 255, 255, 0) 0%, rgba(255, 255, 255, 1) 100%);
+ }
+}
+
+.block-editor-link-control__search-results {
+ margin: 0;
+ padding: $grid-size-large/2 $grid-size-large;
+ max-height: 200px;
+ overflow-y: scroll; // allow results list to scroll
+
+ &.is-loading {
+ opacity: 0.2;
+ }
+}
+
+.block-editor-link-control__search-item {
+ position: relative;
+ display: flex;
+ align-items: center;
+ font-size: $default-font-size;
+ cursor: pointer;
+ background: $white;
+ width: 100%;
+ border: none;
+ text-align: left;
+ padding: 10px 15px;
+ border-radius: 5px;
+
+ &:hover,
+ &:focus {
+ background-color: #e9e9e9;
+ }
+
+ &.is-selected {
+ background: #f2f2f2;
+
+ .block-editor-link-control__search-item-type {
+ background: #fff;
+ }
+ }
+
+ &.is-current {
+ background: transparent;
+ border: 0;
+ width: 100%;
+ cursor: default;
+ padding: $grid-size-large;
+ padding-left: $grid-size-xlarge;
+ }
+
+ .block-editor-link-control__search-item-header {
+ display: block;
+ margin-right: $grid-size-xlarge;
+ }
+
+ .block-editor-link-control__search-item-icon {
+ margin-right: 1em;
+ min-width: 24px;
+ }
+
+ .block-editor-link-control__search-item-info,
+ .block-editor-link-control__search-item-title {
+ text-overflow: ellipsis;
+ max-width: 230px;
+ overflow: hidden;
+ white-space: nowrap;
+ }
+
+ .block-editor-link-control__search-item-title {
+ display: block;
+ margin-bottom: 0.2em;
+ font-weight: 500;
+
+ mark {
+ font-weight: 700;
+ color: #000;
+ background-color: transparent;
+ }
+
+ span {
+ font-weight: normal;
+ }
+ }
+
+ .block-editor-link-control__search-item-info {
+ display: block;
+ color: #999;
+ font-size: 0.9em;
+ line-height: 1.3;
+ }
+
+ .block-editor-link-control__search-item-type {
+ display: block;
+ padding: 3px 8px;
+ margin-left: auto;
+ font-size: 0.9em;
+ background-color: #f3f4f5;
+ border-radius: 2px;
+ }
+}
+
+// Specificity overide
+.block-editor-link-control__search-results div[role="menu"] > .block-editor-link-control__search-item.block-editor-link-control__search-item {
+ padding: 10px;
+}
+
+.block-editor-link-control__settings {
+ border-top: 1px solid #e1e1e1;
+ margin: 0;
+ padding: $grid-size-large $grid-size-xlarge;
+
+ :last-child {
+ margin-bottom: 0;
+ }
+}
+
+.block-editor-link-control .block-editor-link-control__search-input .components-spinner {
+ display: block;
+ z-index: 100;
+ float: none;
+
+ &.components-spinner { // Specificity overide
+ position: absolute;
+ top: 70px;
+ left: 50%;
+ right: auto;
+ bottom: auto;
+ margin: 0 auto 16px auto;
+ transform: translateX(-50%);
+ }
+
+
+}
+
+.block-editor-link-control__search-item-action {
+ margin-left: auto; // push to far right hand side
+ flex-shrink: 0;
+}
diff --git a/packages/block-editor/src/components/link-control/test/__snapshots__/index.js.snap b/packages/block-editor/src/components/link-control/test/__snapshots__/index.js.snap
new file mode 100644
index 00000000000000..df68c094eabc63
--- /dev/null
+++ b/packages/block-editor/src/components/link-control/test/__snapshots__/index.js.snap
@@ -0,0 +1,3 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`Basic rendering should display with required props 1`] = `""`;
diff --git a/packages/block-editor/src/components/link-control/test/fixtures/index.js b/packages/block-editor/src/components/link-control/test/fixtures/index.js
new file mode 100644
index 00000000000000..fc974749c982b1
--- /dev/null
+++ b/packages/block-editor/src/components/link-control/test/fixtures/index.js
@@ -0,0 +1,41 @@
+/**
+ * External dependencies
+ */
+import { uniqueId } from 'lodash';
+
+export const fauxEntitySuggestions = [
+ {
+ id: uniqueId(),
+ title: 'Hello Page',
+ type: 'Page',
+ info: '2 days ago',
+ url: `?p=${ uniqueId() }`,
+ },
+ {
+ id: uniqueId(),
+ title: 'Hello Post',
+ type: 'Post',
+ info: '19 days ago',
+ url: `?p=${ uniqueId() }`,
+ },
+ {
+ id: uniqueId(),
+ title: 'Hello Another One',
+ type: 'Page',
+ info: '19 days ago',
+ url: `?p=${ uniqueId() }`,
+ },
+ {
+ id: uniqueId(),
+ title: 'This is another Post with a much longer title just to be really annoying and to try and break the UI',
+ type: 'Post',
+ info: '1 month ago',
+ url: `?p=${ uniqueId() }`,
+ },
+];
+
+// export const fetchFauxEntitySuggestions = async () => fauxEntitySuggestions;
+
+export const fetchFauxEntitySuggestions = () => {
+ return Promise.resolve( fauxEntitySuggestions );
+};
diff --git a/packages/block-editor/src/components/link-control/test/index.js b/packages/block-editor/src/components/link-control/test/index.js
new file mode 100644
index 00000000000000..d6dc506c7687b8
--- /dev/null
+++ b/packages/block-editor/src/components/link-control/test/index.js
@@ -0,0 +1,527 @@
+/**
+ * External dependencies
+ */
+import { render, unmountComponentAtNode } from 'react-dom';
+import { act, Simulate } from 'react-dom/test-utils';
+import { first, last, nth } from 'lodash';
+/**
+ * WordPress dependencies
+ */
+import { useState } from '@wordpress/element';
+import { UP, DOWN, ENTER } from '@wordpress/keycodes';
+/**
+ * Internal dependencies
+ */
+import LinkControl from '../index';
+import { fauxEntitySuggestions, fetchFauxEntitySuggestions } from './fixtures';
+
+function eventLoopTick() {
+ return new Promise( ( resolve ) => setImmediate( resolve ) );
+}
+
+let container = null;
+
+beforeEach( () => {
+ // setup a DOM element as a render target
+ container = document.createElement( 'div' );
+ document.body.appendChild( container );
+} );
+
+afterEach( () => {
+ // cleanup on exiting
+ unmountComponentAtNode( container );
+ container.remove();
+ container = null;
+} );
+
+describe( 'Basic rendering', () => {
+ it( 'should display with required props', () => {
+ act( () => {
+ render(
+ , container
+ );
+ } );
+
+ // Search Input UI
+ const searchInput = container.querySelector( 'input[aria-label="URL"]' );
+
+ // expect( searchInputLabel ).not.toBeNull();
+ expect( searchInput ).not.toBeNull();
+
+ expect( container.innerHTML ).toMatchSnapshot();
+ } );
+} );
+
+describe( 'Searching for a link', () => {
+ it( 'should display loading UI when input is valid but search results have yet to be returned', async () => {
+ const searchTerm = 'Hello';
+
+ let resolver;
+
+ const fauxRequest = () => new Promise( ( resolve ) => {
+ resolver = resolve;
+ } );
+
+ act( () => {
+ render(
+ , container
+ );
+ } );
+
+ // Search Input UI
+ const searchInput = container.querySelector( 'input[aria-label="URL"]' );
+
+ // Simulate searching for a term
+ act( () => {
+ Simulate.change( searchInput, { target: { value: searchTerm } } );
+ } );
+
+ // fetchFauxEntitySuggestions resolves on next "tick" of event loop
+ await eventLoopTick();
+
+ // TODO: select these by aria relationship to autocomplete rather than arbitary selector.
+ const searchResultElements = container.querySelectorAll( '[role="menu"] button[role="menuitem"]' );
+
+ let loadingUI = container.querySelector( '.components-spinner' );
+
+ expect( searchResultElements ).toHaveLength( 0 );
+
+ expect( loadingUI ).not.toBeNull();
+
+ act( () => {
+ resolver( fauxEntitySuggestions );
+ } );
+
+ await eventLoopTick();
+
+ loadingUI = container.querySelector( '.components-spinner' );
+
+ expect( loadingUI ).toBeNull();
+ } );
+
+ it( 'should display only search suggestions when current input value is not URL-like', async ( ) => {
+ const searchTerm = 'Hello world';
+ const firstFauxSuggestion = first( fauxEntitySuggestions );
+
+ act( () => {
+ render(
+ , container
+ );
+ } );
+
+ // Search Input UI
+ const searchInput = container.querySelector( 'input[aria-label="URL"]' );
+
+ // Simulate searching for a term
+ act( () => {
+ Simulate.change( searchInput, { target: { value: searchTerm } } );
+ } );
+
+ // fetchFauxEntitySuggestions resolves on next "tick" of event loop
+ await eventLoopTick();
+
+ // TODO: select these by aria relationship to autocomplete rather than arbitary selector.
+ const searchResultElements = container.querySelectorAll( '[role="listbox"] [role="option"]' );
+ const firstSearchResultItemHTML = first( searchResultElements ).innerHTML;
+ const lastSearchResultItemHTML = last( searchResultElements ).innerHTML;
+
+ expect( searchResultElements ).toHaveLength( fauxEntitySuggestions.length );
+
+ // Sanity check that a search suggestion shows up corresponding to the data
+ expect( firstSearchResultItemHTML ).toEqual( expect.stringContaining( firstFauxSuggestion.title ) );
+ expect( firstSearchResultItemHTML ).toEqual( expect.stringContaining( firstFauxSuggestion.type ) );
+
+ // The fallback URL suggestion should not be shown when input is not URL-like
+ expect( lastSearchResultItemHTML ).not.toEqual( expect.stringContaining( 'URL' ) );
+ } );
+
+ it.each( [
+ [ 'couldbeurlorentitysearchterm' ],
+ [ 'ThisCouldAlsoBeAValidURL' ],
+ ] )( 'should display a URL suggestion as a default fallback for the search term "%s" which could potentially be a valid url.', async ( searchTerm ) => {
+ act( () => {
+ render(
+ , container
+ );
+ } );
+
+ // Search Input UI
+ const searchInput = container.querySelector( 'input[aria-label="URL"]' );
+
+ // Simulate searching for a term
+ act( () => {
+ Simulate.change( searchInput, { target: { value: searchTerm } } );
+ } );
+
+ // fetchFauxEntitySuggestions resolves on next "tick" of event loop
+ await eventLoopTick();
+
+ // TODO: select these by aria relationship to autocomplete rather than arbitary selector.
+ const searchResultElements = container.querySelectorAll( '[role="listbox"] [role="option"]' );
+ const lastSearchResultItemHTML = last( searchResultElements ).innerHTML;
+ const additionalDefaultFallbackURLSuggestionLength = 1;
+
+ // We should see a search result for each of the expect search suggestions
+ // plus 1 additional one for the fallback URL suggestion
+ expect( searchResultElements ).toHaveLength( fauxEntitySuggestions.length + additionalDefaultFallbackURLSuggestionLength );
+
+ // The last item should be a URL search suggestion
+ expect( lastSearchResultItemHTML ).toEqual( expect.stringContaining( searchTerm ) );
+ expect( lastSearchResultItemHTML ).toEqual( expect.stringContaining( 'URL' ) );
+ expect( lastSearchResultItemHTML ).toEqual( expect.stringContaining( 'Press ENTER to add this link' ) );
+ } );
+
+ it( 'should reset the input field and the search results when search term is cleared or reset', async ( ) => {
+ const searchTerm = 'Hello world';
+
+ act( () => {
+ render(
+ , container
+ );
+ } );
+
+ let searchResultElements;
+ let searchInput;
+
+ // Search Input UI
+ searchInput = container.querySelector( 'input[aria-label="URL"]' );
+
+ // Simulate searching for a term
+ act( () => {
+ Simulate.change( searchInput, { target: { value: searchTerm } } );
+ } );
+
+ // fetchFauxEntitySuggestions resolves on next "tick" of event loop
+ await eventLoopTick();
+
+ // TODO: select these by aria relationship to autocomplete rather than arbitary selector.
+ searchResultElements = container.querySelectorAll( '[role="listbox"] [role="option"]' );
+
+ // Check we have definitely rendered some suggestions
+ expect( searchResultElements ).toHaveLength( fauxEntitySuggestions.length );
+
+ // Grab the reset button now it's available
+ const resetUI = container.querySelector( '[aria-label="Reset"]' );
+
+ act( () => {
+ Simulate.click( resetUI );
+ } );
+
+ await eventLoopTick();
+
+ // TODO: select these by aria relationship to autocomplete rather than arbitary selector.
+ searchResultElements = container.querySelectorAll( '[role="listbox"] [role="option"]' );
+ searchInput = container.querySelector( 'input[aria-label="URL"]' );
+
+ expect( searchInput.value ).toBe( '' );
+ expect( searchResultElements ).toHaveLength( 0 );
+ } );
+} );
+
+describe( 'Manual link entry', () => {
+ it.each( [
+ [ 'https://make.wordpress.org' ], // explicit https
+ [ 'http://make.wordpress.org' ], // explicit http
+ [ 'www.wordpress.org' ], // usage of "www"
+ ] )( 'should display a single suggestion result when the current input value is URL-like (eg: %s)', async ( searchTerm ) => {
+ act( () => {
+ render(
+ , container
+ );
+ } );
+
+ // Search Input UI
+ const searchInput = container.querySelector( 'input[aria-label="URL"]' );
+
+ // Simulate searching for a term
+ act( () => {
+ Simulate.change( searchInput, { target: { value: searchTerm } } );
+ } );
+
+ // fetchFauxEntitySuggestions resolves on next "tick" of event loop
+ await eventLoopTick();
+
+ // TODO: select these by aria relationship to autocomplete rather than arbitary selector.
+ const searchResultElements = container.querySelectorAll( '[role="listbox"] [role="option"]' );
+ const firstSearchResultItemHTML = searchResultElements[ 0 ].innerHTML;
+ const expectedResultsLength = 1;
+
+ expect( searchResultElements ).toHaveLength( expectedResultsLength );
+ expect( firstSearchResultItemHTML ).toEqual( expect.stringContaining( searchTerm ) );
+ expect( firstSearchResultItemHTML ).toEqual( expect.stringContaining( 'URL' ) );
+ expect( firstSearchResultItemHTML ).toEqual( expect.stringContaining( 'Press ENTER to add this link' ) );
+ } );
+
+ describe( 'Alternative link protocols and formats', () => {
+ it.each( [
+ [ 'mailto:example123456@wordpress.org', 'mailto' ],
+ [ 'tel:example123456@wordpress.org', 'tel' ],
+ [ '#internal-anchor', 'internal' ],
+ ] )( 'should recognise "%s" as a %s link and handle as manual entry by displaying a single suggestion', async ( searchTerm, searchType ) => {
+ act( () => {
+ render(
+ , container
+ );
+ } );
+
+ // Search Input UI
+ const searchInput = container.querySelector( 'input[aria-label="URL"]' );
+
+ // Simulate searching for a term
+ act( () => {
+ Simulate.change( searchInput, { target: { value: searchTerm } } );
+ } );
+
+ // fetchFauxEntitySuggestions resolves on next "tick" of event loop
+ await eventLoopTick();
+
+ // TODO: select these by aria relationship to autocomplete rather than arbitary selector.
+ const searchResultElements = container.querySelectorAll( '[role="listbox"] [role="option"]' );
+ const firstSearchResultItemHTML = searchResultElements[ 0 ].innerHTML;
+ const expectedResultsLength = 1;
+
+ expect( searchResultElements ).toHaveLength( expectedResultsLength );
+ expect( firstSearchResultItemHTML ).toEqual( expect.stringContaining( searchTerm ) );
+ expect( firstSearchResultItemHTML ).toEqual( expect.stringContaining( searchType ) );
+ expect( firstSearchResultItemHTML ).toEqual( expect.stringContaining( 'Press ENTER to add this link' ) );
+ } );
+ } );
+} );
+
+describe( 'Selecting links', () => {
+ it( 'should display a selected link corresponding to the provided "currentLink" prop', () => {
+ const selectedLink = first( fauxEntitySuggestions );
+
+ const LinkControlConsumer = () => {
+ const [ link ] = useState( selectedLink );
+
+ return (
+
+ );
+ };
+
+ act( () => {
+ render(
+ , container
+ );
+ } );
+
+ // TODO: select by aria role or visible text
+ const currentLink = container.querySelector( '.block-editor-link-control__search-item.is-current' );
+ const currentLinkHTML = currentLink.innerHTML;
+ const currentLinkAnchor = currentLink.querySelector( `[href="${ selectedLink.url }"]` );
+
+ expect( currentLinkHTML ).toEqual( expect.stringContaining( selectedLink.title ) );
+ expect( currentLinkHTML ).toEqual( expect.stringContaining( selectedLink.type ) );
+ expect( currentLinkHTML ).toEqual( expect.stringContaining( 'Change' ) );
+ expect( currentLinkAnchor ).not.toBeNull();
+ } );
+
+ it( 'should remove currently selected link and (re)display search UI when "Change" button is clicked', () => {
+ const selectedLink = first( fauxEntitySuggestions );
+
+ const LinkControlConsumer = () => {
+ const [ link, setLink ] = useState( selectedLink );
+
+ return (
+ setLink( suggestion ) }
+ fetchSearchSuggestions={ fetchFauxEntitySuggestions }
+ />
+ );
+ };
+
+ act( () => {
+ render(
+ , container
+ );
+ } );
+
+ // TODO: select by aria role or visible text
+ let currentLink = container.querySelector( '.block-editor-link-control__search-item.is-current' );
+
+ const currentLinkBtn = currentLink.querySelector( 'button' );
+
+ // Simulate searching for a term
+ act( () => {
+ Simulate.click( currentLinkBtn );
+ } );
+
+ const searchInput = container.querySelector( 'input[aria-label="URL"]' );
+ currentLink = container.querySelector( '.block-editor-link-control__search-item.is-current' );
+
+ // We should be back to showing the search input
+ expect( searchInput ).not.toBeNull();
+ expect( currentLink ).toBeNull();
+ } );
+
+ describe( 'Selection using mouse click', () => {
+ it.each( [
+ [ 'entity', 'hello world', first( fauxEntitySuggestions ) ], // entity search
+ [ 'url', 'https://www.wordpress.org', {
+ id: '1',
+ title: 'https://www.wordpress.org',
+ url: 'https://www.wordpress.org',
+ type: 'URL',
+ } ], // url
+ ] )( 'should display a current selected link UI when a %s suggestion for the search "%s" is clicked', async ( type, searchTerm, selectedLink ) => {
+ const LinkControlConsumer = () => {
+ const [ link, setLink ] = useState( null );
+
+ return (
+ setLink( suggestion ) }
+ fetchSearchSuggestions={ fetchFauxEntitySuggestions }
+ />
+ );
+ };
+
+ act( () => {
+ render(
+ , container
+ );
+ } );
+
+ // Search Input UI
+ const searchInput = container.querySelector( 'input[aria-label="URL"]' );
+
+ // Simulate searching for a term
+ act( () => {
+ Simulate.change( searchInput, { target: { value: searchTerm } } );
+ } );
+
+ // fetchFauxEntitySuggestions resolves on next "tick" of event loop
+ await eventLoopTick();
+
+ // TODO: select these by aria relationship to autocomplete rather than arbitary selector.
+ const searchResultElements = container.querySelectorAll( '[role="listbox"] [role="option"]' );
+
+ const firstSearchSuggestion = first( searchResultElements );
+
+ // Simulate selecting the first of the search suggestions
+ act( () => {
+ Simulate.click( firstSearchSuggestion );
+ } );
+
+ const currentLink = container.querySelector( '.block-editor-link-control__search-item.is-current' );
+ const currentLinkHTML = currentLink.innerHTML;
+ const currentLinkAnchor = currentLink.querySelector( `[href="${ selectedLink.url }"]` );
+
+ // Check that this suggestion is now shown as selected
+ expect( currentLinkHTML ).toEqual( expect.stringContaining( selectedLink.title ) );
+ expect( currentLinkHTML ).toEqual( expect.stringContaining( 'Change' ) );
+ expect( currentLinkAnchor ).not.toBeNull();
+ } );
+ } );
+
+ describe( 'Selection using keyboard', () => {
+ it.each( [
+ [ 'entity', 'hello world', first( fauxEntitySuggestions ) ], // entity search
+ [ 'url', 'https://www.wordpress.org', {
+ id: '1',
+ title: 'https://www.wordpress.org',
+ url: 'https://www.wordpress.org',
+ type: 'URL',
+ } ], // url
+ ] )( 'should display a current selected link UI when an %s suggestion for the search "%s" is selected using the keyboard', async ( type, searchTerm, selectedLink ) => {
+ const LinkControlConsumer = () => {
+ const [ link, setLink ] = useState( null );
+
+ return (
+ setLink( suggestion ) }
+ fetchSearchSuggestions={ fetchFauxEntitySuggestions }
+ />
+ );
+ };
+
+ act( () => {
+ render(
+ , container
+ );
+ } );
+
+ // Search Input UI
+ const searchInput = container.querySelector( 'input[aria-label="URL"]' );
+
+ // Simulate searching for a term
+ act( () => {
+ Simulate.change( searchInput, { target: { value: searchTerm } } );
+ } );
+
+ //fetchFauxEntitySuggestions resolves on next "tick" of event loop
+ await eventLoopTick();
+
+ // Step down into the search results, highlighting the first result item
+ act( () => {
+ Simulate.keyDown( searchInput, { keyCode: DOWN } );
+ } );
+
+ // TODO: select these by aria relationship to autocomplete rather than arbitary selector.
+ const searchResultElements = container.querySelectorAll( '[role="listbox"] [role="option"]' );
+ const firstSearchSuggestion = first( searchResultElements );
+ const secondSearchSuggestion = nth( searchResultElements, 1 );
+
+ let selectedSearchResultElement = container.querySelector( '[role="option"][aria-selected="true"]' );
+
+ // We should have highlighted the first item using the keyboard
+ expect( selectedSearchResultElement ).toEqual( firstSearchSuggestion );
+
+ // Only entity searches contain more than 1 suggestion
+ if ( type === 'entity' ) {
+ // Check we can go down again using the down arrow
+ act( () => {
+ Simulate.keyDown( searchInput, { keyCode: DOWN } );
+ } );
+
+ selectedSearchResultElement = container.querySelector( '[role="option"][aria-selected="true"]' );
+
+ // We should have highlighted the first item using the keyboard
+ expect( selectedSearchResultElement ).toEqual( secondSearchSuggestion );
+
+ // Check we can go back up via up arrow
+ act( () => {
+ Simulate.keyDown( searchInput, { keyCode: UP } );
+ } );
+
+ selectedSearchResultElement = container.querySelector( '[role="option"][aria-selected="true"]' );
+
+ // We should be back to highlighting the first search result again
+ expect( selectedSearchResultElement ).toEqual( firstSearchSuggestion );
+ }
+
+ // Commit the selected item as the current link
+ act( () => {
+ Simulate.keyDown( searchInput, { keyCode: ENTER } );
+ } );
+
+ // Check that the suggestion selected via is now shown as selected
+ const currentLink = container.querySelector( '.block-editor-link-control__search-item.is-current' );
+ const currentLinkHTML = currentLink.innerHTML;
+ const currentLinkAnchor = currentLink.querySelector( `[href="${ selectedLink.url }"]` );
+
+ expect( currentLinkHTML ).toEqual( expect.stringContaining( selectedLink.title ) );
+ expect( currentLinkHTML ).toEqual( expect.stringContaining( 'Change' ) );
+ expect( currentLinkAnchor ).not.toBeNull();
+ } );
+ } );
+} );
diff --git a/packages/block-editor/src/components/link-control/text-highlight.js b/packages/block-editor/src/components/link-control/text-highlight.js
new file mode 100644
index 00000000000000..dc7b35a3d6d2bc
--- /dev/null
+++ b/packages/block-editor/src/components/link-control/text-highlight.js
@@ -0,0 +1,29 @@
+/**
+ * External dependencies
+ */
+import { escapeRegExp } from 'lodash';
+
+/**
+ * WordPress dependencies
+ */
+import {
+ Fragment,
+} from '@wordpress/element';
+
+const TextHighlight = ( { text = '', highlight = '' } ) => {
+ if ( ! highlight.trim() ) {
+ return text;
+ }
+
+ const regex = new RegExp( `(${ escapeRegExp( highlight ) })`, 'gi' );
+ const parts = text.split( regex );
+ return (
+
+ { parts.filter( ( part ) => part ).map( ( part, i ) => (
+ regex.test( part ) ? { part } : { part }
+ ) ) }
+
+ );
+};
+
+export default TextHighlight;
diff --git a/packages/block-editor/src/components/url-input/index.js b/packages/block-editor/src/components/url-input/index.js
index 9652df56c9da74..60e6b0c19c5432 100644
--- a/packages/block-editor/src/components/url-input/index.js
+++ b/packages/block-editor/src/components/url-input/index.js
@@ -1,7 +1,7 @@
/**
* External dependencies
*/
-import { throttle } from 'lodash';
+import { throttle, isFunction } from 'lodash';
import classnames from 'classnames';
import scrollIntoView from 'dom-scroll-into-view';
@@ -14,6 +14,7 @@ import { UP, DOWN, ENTER, TAB } from '@wordpress/keycodes';
import { Spinner, withSpokenMessages, Popover } from '@wordpress/components';
import { withInstanceId, withSafeTimeout, compose } from '@wordpress/compose';
import { withSelect } from '@wordpress/data';
+import { isURL } from '@wordpress/url';
// Since URLInput is rendered in the context of other inputs, but should be
// considered a separate modal node, prevent keyboard events from propagating
@@ -21,12 +22,15 @@ import { withSelect } from '@wordpress/data';
const stopEventPropagation = ( event ) => event.stopPropagation();
class URLInput extends Component {
- constructor( { autocompleteRef } ) {
- super( ...arguments );
+ constructor( props ) {
+ super( props );
this.onChange = this.onChange.bind( this );
this.onKeyDown = this.onKeyDown.bind( this );
- this.autocompleteRef = autocompleteRef || createRef();
+ this.selectLink = this.selectLink.bind( this );
+ this.handleOnClick = this.handleOnClick.bind( this );
+ this.bindSuggestionNode = this.bindSuggestionNode.bind( this );
+ this.autocompleteRef = props.autocompleteRef || createRef();
this.inputRef = createRef();
this.updateSuggestions = throttle( this.updateSuggestions.bind( this ), 200 );
@@ -45,6 +49,7 @@ class URLInput extends Component {
// when already expanded
if ( showSuggestions && selectedSuggestion !== null && ! this.scrollingIntoView ) {
this.scrollingIntoView = true;
+
scrollIntoView( this.suggestionNodes[ selectedSuggestion ], this.autocompleteRef.current, {
onlyScrollIfNeeded: true,
} );
@@ -66,14 +71,17 @@ class URLInput extends Component {
}
updateSuggestions( value ) {
- const { fetchLinkSuggestions } = this.props;
+ const {
+ __experimentalFetchLinkSuggestions: fetchLinkSuggestions,
+ __experimentalHandleURLSuggestions: handleURLSuggestions,
+ } = this.props;
if ( ! fetchLinkSuggestions ) {
return;
}
// Show the suggestions after typing at least 2 characters
// and also for URLs
- if ( value.length < 2 || /^https?:/.test( value ) ) {
+ if ( value.length < 2 || ( ! handleURLSuggestions && isURL( value ) ) ) {
this.setState( {
showSuggestions: false,
selectedSuggestion: null,
@@ -132,6 +140,7 @@ class URLInput extends Component {
onKeyDown( event ) {
const { showSuggestions, selectedSuggestion, suggestions, loading } = this.state;
+
// If the suggestions are not shown or loading, we shouldn't handle the arrow keys
// We shouldn't preventDefault to allow block arrow keys navigation
if ( ! showSuggestions || ! suggestions.length || loading ) {
@@ -223,19 +232,64 @@ class URLInput extends Component {
this.inputRef.current.focus();
}
- static getDerivedStateFromProps( { disableSuggestions }, { showSuggestions } ) {
+ static getDerivedStateFromProps( { value, disableSuggestions }, { showSuggestions, selectedSuggestion } ) {
+ let shouldShowSuggestions = showSuggestions;
+
+ const hasValue = value && value.length;
+
+ if ( ! hasValue ) {
+ shouldShowSuggestions = false;
+ }
+
+ if ( disableSuggestions === true ) {
+ shouldShowSuggestions = false;
+ }
+
return {
- showSuggestions: disableSuggestions === true ? false : showSuggestions,
+ selectedSuggestion: hasValue ? selectedSuggestion : null,
+ showSuggestions: shouldShowSuggestions,
};
}
render() {
- const { value = '', autoFocus = true, instanceId, className, id, isFullWidth, hasBorder } = this.props;
- const { showSuggestions, suggestions, selectedSuggestion, loading } = this.state;
+ const {
+ instanceId,
+ className,
+ id,
+ isFullWidth,
+ hasBorder,
+ __experimentalRenderSuggestions: renderSuggestions,
+ placeholder = __( 'Paste URL or type to search' ),
+ value = '',
+ autoFocus = true,
+ } = this.props;
+
+ const {
+ showSuggestions,
+ suggestions,
+ selectedSuggestion,
+ loading,
+ } = this.state;
const suggestionsListboxId = `block-editor-url-input-suggestions-${ instanceId }`;
const suggestionOptionIdPrefix = `block-editor-url-input-suggestion-${ instanceId }`;
+ const suggestionsListProps = {
+ id: suggestionsListboxId,
+ ref: this.autocompleteRef,
+ role: 'listbox',
+ };
+
+ const buildSuggestionItemProps = ( suggestion, index ) => {
+ return {
+ role: 'option',
+ tabIndex: '-1',
+ id: `${ suggestionOptionIdPrefix }-${ index }`,
+ ref: this.bindSuggestionNode( index ),
+ 'aria-selected': index === selectedSuggestion,
+ };
+ };
+
/* eslint-disable jsx-a11y/no-autofocus */
return (
}
- { showSuggestions && !! suggestions.length &&
+ { isFunction( renderSuggestions ) && showSuggestions && !! suggestions.length && renderSuggestions( {
+ suggestions,
+ selectedSuggestion,
+ suggestionsListProps,
+ buildSuggestionItemProps,
+ isLoading: loading,
+ handleSuggestionClick: this.handleOnClick,
+ } ) }
+
+ { ! isFunction( renderSuggestions ) && showSuggestions && !! suggestions.length &&
{ suggestions.map( ( suggestion, index ) => (
@@ -311,10 +368,15 @@ export default compose(
withSafeTimeout,
withSpokenMessages,
withInstanceId,
- withSelect( ( select ) => {
+ withSelect( ( select, props ) => {
+ // If a link suggestions handler is already provided then
+ // bail
+ if ( isFunction( props.__experimentalFetchLinkSuggestions ) ) {
+ return;
+ }
const { getSettings } = select( 'core/block-editor' );
return {
- fetchLinkSuggestions: getSettings().__experimentalFetchLinkSuggestions,
+ __experimentalFetchLinkSuggestions: getSettings().__experimentalFetchLinkSuggestions,
};
} )
)( URLInput );
diff --git a/packages/block-editor/src/style.scss b/packages/block-editor/src/style.scss
index 8ebb2e487a4f29..ce48fb4b0a1f9b 100644
--- a/packages/block-editor/src/style.scss
+++ b/packages/block-editor/src/style.scss
@@ -18,6 +18,7 @@
@import "./components/contrast-checker/style.scss";
@import "./components/default-block-appender/style.scss";
@import "./components/gradient-picker/control.scss";
+@import "./components/link-control/style.scss";
@import "./components/inner-blocks/style.scss";
@import "./components/inserter-with-shortcuts/style.scss";
@import "./components/inserter/style.scss";
diff --git a/packages/editor/src/components/provider/index.js b/packages/editor/src/components/provider/index.js
index c386f02097167b..e68f9e9ffa928c 100644
--- a/packages/editor/src/components/provider/index.js
+++ b/packages/editor/src/components/provider/index.js
@@ -40,6 +40,7 @@ const fetchLinkSuggestions = async ( search ) => {
id: post.id,
url: post.url,
title: decodeEntities( post.title ) || __( '(no title)' ),
+ type: post.subtype || post.type,
} ) );
};