Skip to content

Commit

Permalink
Experimental Link creation interface (#17846)
Browse files Browse the repository at this point in the history
* 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 #17846 (comment)

* 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 #17557 (comment)

* 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 #17846 (comment)

* 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 #17557 (comment)

* Update prop names for consistency

Addresses #17846 (comment)

* Update line length to improve readability

Addresses #17846 (comment)

* Update to avoid need to utilise partialRight util from lodash

Addresses #17846 (comment)

* 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 #17846 (comment)

* Update to remote isFunction check in favour of direct check

Addresses #17846 (comment)

* 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 #17846 (comment)

* 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 #17846 (comment)

* DRY up conditionals

Addresses #17846 (comment)

* 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 #18110 (comment)

* 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
  • Loading branch information
getdave authored and talldan committed Oct 30, 2019
1 parent 5eb2f23 commit d6ce94c
Show file tree
Hide file tree
Showing 14 changed files with 1,340 additions and 22 deletions.
1 change: 1 addition & 0 deletions packages/block-editor/src/components/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
50 changes: 50 additions & 0 deletions packages/block-editor/src/components/link-control/README.md
Original file line number Diff line number Diff line change
@@ -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
249 changes: 249 additions & 0 deletions packages/block-editor/src/components/link-control/index.js
Original file line number Diff line number Diff line change
@@ -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 (
<div className="block-editor-link-control__search-results-wrapper">
<div { ...suggestionsListProps } className={ resultsListClasses }>
{ suggestions.map( ( suggestion, index ) => (
<LinkControlSearchItem
key={ `${ suggestion.id }-${ suggestion.type }` }
itemProps={ buildSuggestionItemProps( suggestion, index ) }
suggestion={ suggestion }
onClick={ () => onLinkChange( suggestion ) }
isSelected={ index === selectedSuggestion }
isURL={ manualLinkEntryTypes.includes( suggestion.type.toLowerCase() ) }
searchTerm={ inputValue }
/>
) ) }
</div>
</div>
);
};

return (
<Popover
className={ classnames( 'block-editor-link-control', className ) }
onClose={ closeLinkUI }
position="bottom center"
focusOnMount="firstElement"
>
<div className="block-editor-link-control__popover-inner">
<div className="block-editor-link-control__search">

{ ( ! isEditingLink && currentLink ) && (
<Fragment>
<p className="screen-reader-text" id={ `current-link-label-${ instanceId }` }>
{ __( 'Currently selected' ) }:
</p>
<div
aria-labelledby={ `current-link-label-${ instanceId }` }
aria-selected="true"
className={ classnames( 'block-editor-link-control__search-item', {
'is-current': true,
} ) }
>
<span className="block-editor-link-control__search-item-header">

<ExternalLink
className="block-editor-link-control__search-item-title"
href={ currentLink.url }
>
{ currentLink.title }
</ExternalLink>
<span className="block-editor-link-control__search-item-info">{ filterURLForDisplay( safeDecodeURI( currentLink.url ) ) || '' }</span>
</span>

<Button isDefault onClick={ startEditMode } className="block-editor-link-control__search-item-action block-editor-link-control__search-item-action--edit">
{ __( 'Change' ) }
</Button>
</div>
</Fragment>
) }

{ isEditingLink && (
<LinkControlSearchInput
value={ inputValue }
onChange={ onInputChange }
onSelect={ onLinkChange }
renderSuggestions={ renderSearchResults }
fetchSuggestions={ getSearchHandler }
onReset={ resetInput }
onKeyDown={ onKeyDown }
onKeyPress={ onKeyPress }
/>
) }

{ ! isEditingLink && (
<LinkControlSettingsDrawer settings={ currentSettings } onSettingChange={ onSettingsChange } />
) }
</div>
</div>
</Popover>
);
}

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 );
69 changes: 69 additions & 0 deletions packages/block-editor/src/components/link-control/search-input.js
Original file line number Diff line number Diff line change
@@ -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 (
<form onSubmit={ stopFormEventsPropagation }>
<URLInput
className="block-editor-link-control__search-input"
value={ value }
onChange={ selectItemHandler }
onKeyDown={ ( event ) => {
if ( event.keyCode === ENTER ) {
return;
}
onKeyDown( event );
} }
onKeyPress={ onKeyPress }
placeholder={ __( 'Search or type url' ) }
__experimentalRenderSuggestions={ renderSuggestions }
__experimentalFetchLinkSuggestions={ fetchSuggestions }
__experimentalHandleURLSuggestions={ true }
/>

<IconButton
disabled={ ! value.length }
type="reset"
label={ __( 'Reset' ) }
icon="no-alt"
className="block-editor-link-control__search-reset"
onClick={ onReset }
/>

</form>
);
};

export default LinkControlSearchInput;
Loading

0 comments on commit d6ce94c

Please sign in to comment.