-
Notifications
You must be signed in to change notification settings - Fork 4.2k
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Experimental Link creation interface #17846
Changes from all commits
8dbc2bd
ee008be
e685003
70049ad
98e6aa5
85a6317
8ec1488
140cc90
d64608e
30e0eb6
53ce517
b53c13c
6d7259a
4f32429
4971e30
4aea19c
d554b65
26c4c1b
e778a04
3ca482e
aec9f39
70bd877
58220f8
5eeb819
d355133
2044065
b256d55
b55f557
2133fb1
70c5c04
4a031ed
5b5164c
664daad
9780959
5c3bf35
c962dc6
d4c0a70
8cff4c2
b4a3f66
fbe49e7
fa0dd36
f5a7e65
a88826e
ec9e2ad
11ca79f
779d440
4941bb8
452ec55
5484a7c
37de5d9
ba32148
33467f3
553be99
ad46c87
26c292f
17be2f8
13f5ce1
556088a
1cdd939
2588b9e
a065936
98836fd
5254015
811ad11
9cd6d18
bdb6217
d0a348b
da212f0
19d5e64
548279b
a844800
fd3a6ef
b314c49
5aeb531
1a7c285
63201c9
4918184
cd29ab5
2d8befb
d8895d5
2413016
e3042c8
0a9d558
89e92fb
ff57160
48e5f44
4e811db
43c30b2
158ea3e
4981a71
f1c54a6
797fd6c
e5e44e6
5ba4b65
d5abad2
5c1ec22
4d5b455
b8a01bd
7df9aa5
b6edb73
b435b49
8c5ed79
1fffe8e
1c1614d
0cdf706
2f80347
b93a0db
5b6715d
280db32
ba985eb
7d35791
e7585b8
8450bb1
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
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 |
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 ]; | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I think it might be tidier if all the code from Other than that, this could use an array spread:
|
||
}; | ||
|
||
// Effects | ||
const getSearchHandler = useCallback( ( value ) => { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
|
||
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 } | ||
talldan marked this conversation as resolved.
Show resolved
Hide resolved
|
||
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"> | ||
aduth marked this conversation as resolved.
Show resolved
Hide resolved
|
||
{ __( '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 ); |
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; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Was there any planned usage for the various
type
s associated with manual entry? As part of the ongoing discussion in #18061, I've been considering whether this is something where the explicit absence of the type could be a reasonably good indicator on its own for marking a value as "manual entry". The sorts of things that someone might want to do withtype
should be equally achievable with the@wordpress/url
utilities, if a developer were so inclined. One of the issues I encountered in #19827 was trying to decide what the best complete set of these "subtypes" should be, where we would probably be better off to just not have to answer that.There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Follow-up: #20051