From d6ce94c50df8f07932916e6686ba1ad2ffe91525 Mon Sep 17 00:00:00 2001 From: Dave Smith Date: Wed, 30 Oct 2019 04:21:33 +0000 Subject: [PATCH] Experimental Link creation interface (#17846) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Initial component file structure * Implement basic icon and toggle mechanic * Adds basic search input * Update input to utilise LinkEditor component autocomplete * Add ability to customise placeholder * Update to utilise URLInput directly for greater flexibility * Add example search results and test coverage * Update class naming convention to match guidelines See https://github.com/WordPress/gutenberg/blob/master/docs/contributors/coding-guidelines.md#css Addresses https://github.com/WordPress/gutenberg/pull/17846#discussion_r332567521 * Adds render prop to enable custom suggestions rendering Previously it wasn’t possible to customise the render of the search suggestions. By providing an optional render prop we now have full control over this if required. * Update to utilise URLInput render prop to customise search suggestions render Previously we relied on our own render of suggestions but this wasn’t hooked up to all the accessibility enhancements afforded by URLInput. By utilising the render prop exposed by URLInput to customise the rendering of suggestions, we can have the best of both worlds. * Update to add post type to the fetchLinkSuggestions responsive mapping This is required to display the type of entitity in the search results for LinkControl * Fix to ensure search suggestion interaction states are perceivable * Update suggestion render prop to provide component props as arguments Previously when using the `renderSuggestions` render prop the user had to know how to put together the correct props on the correct elements in their custom render. By passing the default props for the listing element and the item element we can relieve the user of this burden by allowing them to spread the props onto the appropriate elements in their render without having to know how they are created. * Update to match with design visual and provide more accessible markup * Adds settings area. Fixes missing reset icon. * Fix search items to be buttons with correct style and layout * Adds overflow scrolling to search results * Fix to stop scroll shadow overlaying scrollbars * Add bespoke settings area and tweak styles * Update to allow URLs to be conditionally handled as a suggestion Previously when a URL was entered it was deemed that no suggestions should or could be found and so the process of fetching suggestions was short circuited. Add additional prop to optionally allow developers to have URL-like values handled as suggestions. * Updates to conditionally use an entity or url based search results fetcher If the current value of the input is a URL then we conditionally pass a different handler for search results to the URLInput component. For URL based values we immediately return a “suggestion” object with values matching those entered by the user. Non URL based values are handled as previously. * Fix bug whereby fetchSearchSuggestions wasn’t called Remove ambiguity by calling the search handler directly rather than proxying through another function and having to apply it immediately. * Remove default toggle UI and implement Popover close The LinkControl will be mostly where another element triggers the UI to appear. As a result we don’t want to force a toggle element on the developer. Rather we will expose an API to allow the consuming component to toggle the visibility of the LinkControl * Adds search text “highlighting” in results list * Move TextHighlight component to its own file * Fix bug where update to value prop didn’t cause suggestions to reset. * Update to remove internal handling of open/closed state This state is now expected to be handled by the consuming component chosing whether or not to render the component. It has no concept of open or closed. * Fix React violation by returning only the text for non matches * Update existing tests to match new implementation * Add link reset test * Adds test which uncovers major bug in the implementation Basically this test has revealed that due to the way we’re detecting and handling URL-like values the wrong data fetcher function gets passed to the URLInput component for the first input `change` event. For example if you paste `https://make.wordpress.com` directly into the input then it is determined to be a URL but because the current fetcher function for the current render is still the handler that deals with entity searches the correct results are not displayed. Adding another character to trigger a re-render will cause the UI to update to the expected state, but this is a major bug. * Tweak critical test to be more explicit about what is expected * Fix bug to make determining search handler use the latest input value Previously we relied on parent component state to choose which search handler to use for the current input term. However, the state was always 1 tick behind so the previous search handler got used. Updating this to use the real time value of the input passed onChange ensures we select the correct search fetcher when the component re-renders. * Add loading spinner and associated test coverage Spinner was technically always rendered but it wasn’t visible due to CSS styling. Fix and also cover with tests. * Fix bug where value could be empty * Adds basic editing / view state switching * Add keydown callback to URLInput * Select link on ENTER keydown event * Utilise LinkViewer to render edit state and decode urls for display * Only display link settings when a link is selected * Adds current link view styles * Makes settings toggle controlled by parent component * Update visuals to match updated design Addresses https://github.com/WordPress/gutenberg/issues/17557#issuecomment-542401433 * Add standardised min width to popover * Temporary hack to include Link UI in Playground for testing * Update to utilise isURL util from @wordpress/url package * Update to utilise isURL util from @wordpress/url package * Removes URLPopover dependency Attempts to remove unwanted deps on other components. We now utilise Popover directly and suffer no consequences as we are not making use of any bespoke features provided by URLPopover. * Extract settings drawer to sub component * Refactor search items into a component * Refactor Input and Search to component * Fix missing selected state on search suggestions * Tweak line height on search suggestion url path * Augment test for URL-like by testing for “www.” * Fix to stop url overflows and wrapping on to multiple lines * Uppcase URL in type indicator within search results list * Avoid reading out slug/URL for entity results * Ensures i18n of change button * Always offer URL result in search suggestions as default * Fix loading spinner position and dim results during loading Addresses https://github.com/WordPress/gutenberg/pull/17846#issuecomment-543244810 * Fix scroll shadows to use valid alpha transparent values in gradient Fixes broken shadows in Safari which didn’t recognise transparent as a value to transition to in a gradient. * Adds instructional text in place of URL for suggestions that are URLs Addresses designer feedback https://github.com/WordPress/gutenberg/issues/17557#issuecomment-545030027 * Update prop names for consistency Addresses https://github.com/WordPress/gutenberg/pull/17846#discussion_r337840953 * Update line length to improve readability Addresses https://github.com/WordPress/gutenberg/pull/17846#discussion_r337842799 * Update to avoid need to utilise partialRight util from lodash Addresses https://github.com/WordPress/gutenberg/pull/17846#discussion_r337882576 * Updates key to avoid usage of index We cannot assume the suggestion `id` will be unique. This is because at the moment the search results are `Post`s. However in the future we may also need to include `Category` terms and the term IDs could easily clash with the Post IDs as they are in different DB tables. Using the `type` to differentiate the key. Addresses https://github.com/WordPress/gutenberg/pull/17846#discussion_r337883174 * Update to remote isFunction check in favour of direct check Addresses https://github.com/WordPress/gutenberg/pull/17846#discussion_r337885206 * Update to handle mailto and tel protocols and internal links * url-input: handle onKeyPress type event * link-control: add className prop * link-control: add README file * Remove unnecessary use of useCallback Addresses https://github.com/WordPress/gutenberg/pull/17846#discussion_r338363236 * Fix current automated tests * Improves URL handling test to run for multiple URL value variations * Updates to display the URL type in the search results Previously only true `http` URLs were formatted with the correct type and the instructional text. Fixes so that all types of manual URL entry are correctly shown as such in the search results. Adds test to cover mailto variant of this. * Refactor tests to assert against all valid protocol formats and link variants This now includes tel, mailto and internal links. * Adds test to cover display of fallback URL search result for search values that are potentially URLS * Adds tests to check URL suggestions don’t display for non-URLs. * url-input: remove unneeded `suggestion` const * url-input: always trigger onKeyDown event * link-control: delegate handling keydown event Instead of this, let's propagate the onKeyDown and onKeyPress events to the parent component * link-control: add onKeyDown and onKeyPress handlers * link-control: playground -> close once onClose * link-control: propagate onClose() event * link-control: playground -> hanldling close by ESCAPE key * Fix to only render settings draw if settings are defined * Remove redundant commented out test * Update to render with a “current link” if one is provided. Previously if you passed in a current link the component would still render with a search box as thought nothing was selected. Updates so that if `currentLink` is provided the UI reflects that by showing the “selected” item and no search input. * Render playground with currentLink active * Adds test to cover currentLink prop * Remove selected state from Playground * Adds tests to cover selecting and changing links * Remove async function in place of direct Promise usage and add test coverage * Add test to cover keyboard handling Note: this uncovered a bug whereby keyboard handling of “selecting” the link you want to use is broken. This needs to be fixed. * Remove unecessary dep from effect * Fix URLInput to pass the actual suggestion object not the index If the full object is not provided then consuming components have no way of accessing the details of the selected suggestion thereby rendering it useless. * Fix keyboard handling so hitting `ENTER` will select an item as the current link Builds on previous commit. * Updates keyboard interaction test to include URL entry * Minor: reword test description * Fix missing key prop regression Previously `buildSuggestionItemProps` was including a key. However the implementation of `LinkControl` changed so that this was not required. However we forgot to reinstate on `URLInput`. This update ensures a key prop is set on the default output. Note that disabling of the autofocus linting was already in place: https://github.com/WordPress/gutenberg/blob/04e142e9cbd06a45c4ea297ec573d389955c13be/packages/block-editor/src/components/url-input/index.js#L239 Addresses https://github.com/WordPress/gutenberg/pull/17846#discussion_r337841961 * DRY up conditionals Addresses https://github.com/WordPress/gutenberg/pull/17846#discussion_r337842477 * link-control: set a default experimental link suggestions searcher if it't needed * link-control: handling key events * url-input: remove onKeyDown prop * url-input: remove calling onKeyDown prop * url-input: rollback some changes * Mark Link Creation Interface as Experimental (#18110) * mark main component as experimental * mark new URLInput props as experimental * add experimental onKeyPress * remove key handlers * Updates to use alias on experimental props Addresses https://github.com/WordPress/gutenberg/pull/18110#discussion_r339427180 * Remove unused prop from docs * Update props ordering and readme docs Also fixes eslint errors that kept me from committing the original changes * Revert playground changes * Rename InputSearch to SearchInput Props @talldan I really hope those changes I had to make in `search-input.js` don't break anything. * Remove disabling of jsx-key lint rule * Change fake id value to something that will not clash with post ids --- packages/block-editor/src/components/index.js | 1 + .../src/components/link-control/README.md | 50 ++ .../src/components/link-control/index.js | 249 +++++++++ .../components/link-control/search-input.js | 69 +++ .../components/link-control/search-item.js | 54 ++ .../link-control/settings-drawer.js | 29 + .../src/components/link-control/style.scss | 202 +++++++ .../test/__snapshots__/index.js.snap | 3 + .../link-control/test/fixtures/index.js | 41 ++ .../src/components/link-control/test/index.js | 527 ++++++++++++++++++ .../components/link-control/text-highlight.js | 29 + .../src/components/url-input/index.js | 106 +++- packages/block-editor/src/style.scss | 1 + .../editor/src/components/provider/index.js | 1 + 14 files changed, 1340 insertions(+), 22 deletions(-) create mode 100644 packages/block-editor/src/components/link-control/README.md create mode 100644 packages/block-editor/src/components/link-control/index.js create mode 100644 packages/block-editor/src/components/link-control/search-input.js create mode 100644 packages/block-editor/src/components/link-control/search-item.js create mode 100644 packages/block-editor/src/components/link-control/settings-drawer.js create mode 100644 packages/block-editor/src/components/link-control/style.scss create mode 100644 packages/block-editor/src/components/link-control/test/__snapshots__/index.js.snap create mode 100644 packages/block-editor/src/components/link-control/test/fixtures/index.js create mode 100644 packages/block-editor/src/components/link-control/test/index.js create mode 100644 packages/block-editor/src/components/link-control/text-highlight.js diff --git a/packages/block-editor/src/components/index.js b/packages/block-editor/src/components/index.js index 7ec60098a8c737..9b094396d9d8b0 100644 --- a/packages/block-editor/src/components/index.js +++ b/packages/block-editor/src/components/index.js @@ -26,6 +26,7 @@ export { default as __experimentalGradientPickerPanel } from './gradient-picker/ 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 ( +
+ { + if ( event.keyCode === ENTER ) { + return; + } + onKeyDown( event ); + } } + onKeyPress={ onKeyPress } + placeholder={ __( 'Search or type url' ) } + __experimentalRenderSuggestions={ renderSuggestions } + __experimentalFetchLinkSuggestions={ fetchSuggestions } + __experimentalHandleURLSuggestions={ true } + /> + + + + + ); +}; + +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 53a5914f10b4a9..1c32d9c29eab34 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 ( @@ -226,19 +235,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 ) => ( @@ -314,10 +371,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 7296c0fa788620..9c149f001cf7fa 100644 --- a/packages/block-editor/src/style.scss +++ b/packages/block-editor/src/style.scss @@ -19,6 +19,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 d38ceaa91e66d2..37eb46ff235b52 100644 --- a/packages/editor/src/components/provider/index.js +++ b/packages/editor/src/components/provider/index.js @@ -38,6 +38,7 @@ const fetchLinkSuggestions = async ( search ) => { id: post.id, url: post.url, title: decodeEntities( post.title ) || __( '(no title)' ), + type: post.subtype || post.type, } ) ); };