/** * External dependencies */ import classnames from 'classnames'; import { noop, startsWith } from 'lodash'; /** * WordPress dependencies */ import { Button, ExternalLink, Spinner, VisuallyHidden, createSlotFill, } from '@wordpress/components'; import { __, sprintf } from '@wordpress/i18n'; import { useRef, useCallback, useState, Fragment, useEffect, createElement, useMemo, } from '@wordpress/element'; import { safeDecodeURI, filterURLForDisplay, isURL, prependHTTP, getProtocol, } from '@wordpress/url'; import { useInstanceId } from '@wordpress/compose'; import { useSelect } from '@wordpress/data'; import { focus } from '@wordpress/dom'; /** * Internal dependencies */ import LinkControlSettingsDrawer from './settings-drawer'; import LinkControlSearchItem from './search-item'; import LinkControlSearchInput from './search-input'; import LinkControlSearchCreate from './search-create-button'; const { Slot: ViewerSlot, Fill: ViewerFill } = createSlotFill( 'BlockEditorLinkControlViewer' ); // Used as a unique identifier for the "Create" option within search results. // Used to help distinguish the "Create" suggestion within the search results in // order to handle it as a unique case. const CREATE_TYPE = '__CREATE__'; /** * Creates a wrapper around a promise which allows it to be programmatically * cancelled. * See: https://reactjs.org/blog/2015/12/16/ismounted-antipattern.html * * @param {Promise} promise the Promise to make cancelable */ const makeCancelable = ( promise ) => { let hasCanceled_ = false; const wrappedPromise = new Promise( ( resolve, reject ) => { promise.then( ( val ) => hasCanceled_ ? reject( { isCanceled: true } ) : resolve( val ), ( error ) => hasCanceled_ ? reject( { isCanceled: true } ) : reject( error ) ); } ); return { promise: wrappedPromise, cancel() { hasCanceled_ = true; }, }; }; /** * Default properties associated with a link control value. * * @typedef WPLinkControlDefaultValue * * @property {string} url Link URL. * @property {string=} title Link title. * @property {boolean=} opensInNewTab Whether link should open in a new browser * tab. This value is only assigned if not * providing a custom `settings` prop. */ /* eslint-disable jsdoc/valid-types */ /** * Custom settings values associated with a link. * * @typedef {{[setting:string]:any}} WPLinkControlSettingsValue */ /* eslint-enable */ /** * Custom settings values associated with a link. * * @typedef WPLinkControlSetting * * @property {string} id Identifier to use as property for setting value. * @property {string} title Human-readable label to show in user interface. */ /* eslint-disable jsdoc/valid-types */ /** * Properties associated with a link control value, composed as a union of the * default properties and any custom settings values. * * @typedef {WPLinkControlDefaultValue&WPLinkControlSettingsValue} WPLinkControlValue */ /* eslint-enable */ /** @typedef {(nextValue:WPLinkControlValue)=>void} WPLinkControlOnChangeProp */ /** * Properties associated with a search suggestion used within the LinkControl. * * @typedef WPLinkControlSuggestion * * @property {string} id Identifier to use to uniquely identify the suggestion. * @property {string} type Identifies the type of the suggestion (eg: `post`, * `page`, `url`...etc) * @property {string} title Human-readable label to show in user interface. * @property {string} url A URL for the suggestion. */ /** @typedef {(title:string)=>WPLinkControlSuggestion} WPLinkControlCreateSuggestionProp */ /** * @typedef WPLinkControlProps * * @property {(WPLinkControlSetting[])=} settings An array of settings objects. Each object will used to * render a `ToggleControl` for that setting. * @property {boolean=} forceIsEditingLink If passed as either `true` or `false`, controls the * internal editing state of the component to respective * show or not show the URL input field. * @property {WPLinkControlValue=} value Current link value. * @property {WPLinkControlOnChangeProp=} onChange Value change handler, called with the updated value if * the user selects a new link or updates settings. * @property {boolean=} showSuggestions Whether to present suggestions when typing the URL. * @property {boolean=} showInitialSuggestions Whether to present initial suggestions immediately. * @property {WPLinkControlCreateSuggestionProp=} createSuggestion Handler to manage creation of link value from suggestion. */ /** * Renders a link control. A link control is a controlled input which maintains * a value associated with a link (HTML anchor element) and relevant settings * for how that link is expected to behave. * * @param {WPLinkControlProps} props Component props. */ function LinkControl( { value, settings, onChange = noop, showSuggestions = true, showInitialSuggestions, forceIsEditingLink, createSuggestion, } ) { const cancelableOnCreate = useRef(); const cancelableCreateSuggestion = useRef(); const wrapperNode = useRef(); const instanceId = useInstanceId( LinkControl ); const [ inputValue, setInputValue ] = useState( ( value && value.url ) || '' ); const [ isEditingLink, setIsEditingLink ] = useState( forceIsEditingLink !== undefined ? forceIsEditingLink : ! value || ! value.url ); const [ isResolvingLink, setIsResolvingLink ] = useState( false ); const [ errorMessage, setErrorMessage ] = useState( null ); const isEndingEditWithFocus = useRef( false ); const { fetchSearchSuggestions } = useSelect( ( select ) => { const { getSettings } = select( 'core/block-editor' ); return { fetchSearchSuggestions: getSettings() .__experimentalFetchLinkSuggestions, }; }, [] ); const displayURL = ( value && filterURLForDisplay( safeDecodeURI( value.url ) ) ) || ''; useEffect( () => { if ( forceIsEditingLink !== undefined && forceIsEditingLink !== isEditingLink ) { setIsEditingLink( forceIsEditingLink ); } }, [ forceIsEditingLink ] ); useEffect( () => { // When `isEditingLink` is set to `false`, a focus loss could occur // since the link input may be removed from the DOM. To avoid this, // reinstate focus to a suitable target if focus has in-fact been lost. // Note that the check is necessary because while typically unsetting // edit mode would render the read-only mode's link element, it isn't // guaranteed. The link input may continue to be shown if the next value // is still unassigned after calling `onChange`. const hadFocusLoss = isEndingEditWithFocus.current && wrapperNode.current && ! wrapperNode.current.contains( document.activeElement ); if ( hadFocusLoss ) { // Prefer to focus a natural focusable descendent of the wrapper, // but settle for the wrapper if there are no other options. const nextFocusTarget = focus.focusable.find( wrapperNode.current )[ 0 ] || wrapperNode.current; nextFocusTarget.focus(); } isEndingEditWithFocus.current = false; }, [ isEditingLink ] ); /** * Handles cancelling any pending Promises that have been made cancelable. */ useEffect( () => { return () => { // componentDidUnmount if ( cancelableOnCreate.current ) { cancelableOnCreate.current.cancel(); } if ( cancelableCreateSuggestion.current ) { cancelableCreateSuggestion.current.cancel(); } }; }, [] ); /** * onChange LinkControlSearchInput event handler * * @param {string} val Current value returned by the search. */ const onInputChange = ( val = '' ) => { setInputValue( val ); }; const handleDirectEntry = ( val ) => { let type = 'URL'; const protocol = getProtocol( val ) || ''; if ( protocol.includes( 'mailto' ) ) { type = 'mailto'; } if ( protocol.includes( 'tel' ) ) { type = 'tel'; } if ( startsWith( val, '#' ) ) { type = 'internal'; } return Promise.resolve( [ { id: val, title: val, url: type === 'URL' ? prependHTTP( val ) : val, type, }, ] ); }; const handleEntitySearch = async ( val, args ) => { let results = await Promise.all( [ fetchSearchSuggestions( val, { ...( args.isInitialSuggestions ? { perPage: 3 } : {} ), } ), handleDirectEntry( val ), ] ); const couldBeURL = ! val.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. results = couldBeURL && ! args.isInitialSuggestions ? results[ 0 ].concat( results[ 1 ] ) : results[ 0 ]; // Here we append a faux suggestion to represent a "CREATE" option. This // is detected in the rendering of the search results and handled as a // special case. This is currently necessary because the suggestions // dropdown will only appear if there are valid suggestions and // therefore unless the create option is a suggestion it will not // display in scenarios where there are no results returned from the // API. In addition promoting CREATE to a first class suggestion affords // the a11y benefits afforded by `URLInput` to all suggestions (eg: // keyboard handling, ARIA roles...etc). // // Note also that the value of the `title` and `url` properties must correspond // to the text value of the ``. This is because `title` is used // when creating the suggestion. Similarly `url` is used when using keyboard to select // the suggestion (the