@wordpress/block-editor
Version:
484 lines (469 loc) • 16 kB
JavaScript
/**
* External dependencies
*/
import clsx from 'clsx';
/**
* WordPress dependencies
*/
import { __, sprintf, _n } from '@wordpress/i18n';
import { Component, createRef } from '@wordpress/element';
import { UP, DOWN, ENTER, TAB } from '@wordpress/keycodes';
import { BaseControl, Button, __experimentalInputControl as InputControl, Spinner, withSpokenMessages, Popover } from '@wordpress/components';
import { compose, debounce, withInstanceId, withSafeTimeout } from '@wordpress/compose';
import { withSelect } from '@wordpress/data';
import { isURL } from '@wordpress/url';
/**
* Internal dependencies
*/
import { store as blockEditorStore } from '../../store';
/**
* Whether the argument is a function.
*
* @param {*} maybeFunc The argument to check.
* @return {boolean} True if the argument is a function, false otherwise.
*/
import { Fragment as _Fragment, jsxs as _jsxs, jsx as _jsx } from "react/jsx-runtime";
import { createElement as _createElement } from "react";
function isFunction(maybeFunc) {
return typeof maybeFunc === 'function';
}
class URLInput extends Component {
constructor(props) {
super(props);
this.onChange = this.onChange.bind(this);
this.onFocus = this.onFocus.bind(this);
this.onKeyDown = this.onKeyDown.bind(this);
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 = debounce(this.updateSuggestions.bind(this), 200);
this.suggestionNodes = [];
this.suggestionsRequest = null;
this.state = {
suggestions: [],
showSuggestions: false,
suggestionsValue: null,
selectedSuggestion: null,
suggestionsListboxId: '',
suggestionOptionIdPrefix: ''
};
}
componentDidUpdate(prevProps) {
const {
showSuggestions,
selectedSuggestion
} = this.state;
const {
value,
__experimentalShowInitialSuggestions = false
} = this.props;
// Only have to worry about scrolling selected suggestion into view
// when already expanded.
if (showSuggestions && selectedSuggestion !== null && this.suggestionNodes[selectedSuggestion]) {
this.suggestionNodes[selectedSuggestion].scrollIntoView({
behavior: 'instant',
block: 'nearest',
inline: 'nearest'
});
}
// Update suggestions when the value changes.
if (prevProps.value !== value && !this.props.disableSuggestions) {
if (value?.length) {
// If the new value is not empty we need to update with suggestions for it.
this.updateSuggestions(value);
} else if (__experimentalShowInitialSuggestions) {
// If the new value is empty and we can show initial suggestions, then show initial suggestions.
this.updateSuggestions();
}
}
}
componentDidMount() {
if (this.shouldShowInitialSuggestions()) {
this.updateSuggestions();
}
}
componentWillUnmount() {
this.suggestionsRequest?.cancel?.();
this.suggestionsRequest = null;
}
bindSuggestionNode(index) {
return ref => {
this.suggestionNodes[index] = ref;
};
}
shouldShowInitialSuggestions() {
const {
__experimentalShowInitialSuggestions = false,
value
} = this.props;
return __experimentalShowInitialSuggestions && !(value && value.length);
}
updateSuggestions(value = '') {
const {
__experimentalFetchLinkSuggestions: fetchLinkSuggestions,
__experimentalHandleURLSuggestions: handleURLSuggestions
} = this.props;
if (!fetchLinkSuggestions) {
return;
}
// Initial suggestions may only show if there is no value
// (note: this includes whitespace).
const isInitialSuggestions = !value?.length;
// Trim only now we've determined whether or not it originally had a "length"
// (even if that value was all whitespace).
value = value.trim();
// Allow a suggestions request if:
// - there are at least 2 characters in the search input (except manual searches where
// search input length is not required to trigger a fetch)
// - this is a direct entry (eg: a URL)
if (!isInitialSuggestions && (value.length < 2 || !handleURLSuggestions && isURL(value))) {
this.suggestionsRequest?.cancel?.();
this.suggestionsRequest = null;
this.setState({
suggestions: [],
showSuggestions: false,
suggestionsValue: value,
selectedSuggestion: null,
loading: false
});
return;
}
this.setState({
selectedSuggestion: null,
loading: true
});
const request = fetchLinkSuggestions(value, {
isInitialSuggestions
});
request.then(suggestions => {
// A fetch Promise doesn't have an abort option. It's mimicked by
// comparing the request reference in on the instance, which is
// reset or deleted on subsequent requests or unmounting.
if (this.suggestionsRequest !== request) {
return;
}
this.setState({
suggestions,
suggestionsValue: value,
loading: false,
showSuggestions: !!suggestions.length
});
if (!!suggestions.length) {
this.props.debouncedSpeak(sprintf(/* translators: %d: number of results. */
_n('%d result found, use up and down arrow keys to navigate.', '%d results found, use up and down arrow keys to navigate.', suggestions.length), suggestions.length), 'assertive');
} else {
this.props.debouncedSpeak(__('No results.'), 'assertive');
}
}).catch(() => {
if (this.suggestionsRequest !== request) {
return;
}
this.setState({
loading: false
});
}).finally(() => {
// If this is the current promise then reset the reference
// to allow for checking if a new request is made.
if (this.suggestionsRequest === request) {
this.suggestionsRequest = null;
}
});
// Note that this assignment is handled *before* the async search request
// as a Promise always resolves on the next tick of the event loop.
this.suggestionsRequest = request;
}
onChange(newValue) {
this.props.onChange(newValue);
}
onFocus() {
const {
suggestions
} = this.state;
const {
disableSuggestions,
value
} = this.props;
// When opening the link editor, if there's a value present, we want to load the suggestions pane with the results for this input search value
// Don't re-run the suggestions on focus if there are already suggestions present (prevents searching again when tabbing between the input and buttons)
// or there is already a request in progress.
if (value && !disableSuggestions && !(suggestions && suggestions.length) && this.suggestionsRequest === null) {
// Ensure the suggestions are updated with the current input value.
this.updateSuggestions(value);
}
}
onKeyDown(event) {
this.props.onKeyDown?.(event);
const {
showSuggestions,
selectedSuggestion,
suggestions,
loading
} = this.state;
// If the suggestions are not shown or loading, we shouldn't handle the arrow keys
// We shouldn't preventDefault to allow block arrow keys navigation.
if (!showSuggestions || !suggestions.length || loading) {
// In the Windows version of Firefox the up and down arrows don't move the caret
// within an input field like they do for Mac Firefox/Chrome/Safari. This causes
// a form of focus trapping that is disruptive to the user experience. This disruption
// only happens if the caret is not in the first or last position in the text input.
// See: https://github.com/WordPress/gutenberg/issues/5693#issuecomment-436684747
switch (event.keyCode) {
// When UP is pressed, if the caret is at the start of the text, move it to the 0
// position.
case UP:
{
if (0 !== event.target.selectionStart) {
event.preventDefault();
// Set the input caret to position 0.
event.target.setSelectionRange(0, 0);
}
break;
}
// When DOWN is pressed, if the caret is not at the end of the text, move it to the
// last position.
case DOWN:
{
if (this.props.value.length !== event.target.selectionStart) {
event.preventDefault();
// Set the input caret to the last position.
event.target.setSelectionRange(this.props.value.length, this.props.value.length);
}
break;
}
// Submitting while loading should trigger onSubmit.
case ENTER:
{
if (this.props.onSubmit) {
event.preventDefault();
this.props.onSubmit(null, event);
}
break;
}
}
return;
}
const suggestion = this.state.suggestions[this.state.selectedSuggestion];
switch (event.keyCode) {
case UP:
{
event.preventDefault();
const previousIndex = !selectedSuggestion ? suggestions.length - 1 : selectedSuggestion - 1;
this.setState({
selectedSuggestion: previousIndex
});
break;
}
case DOWN:
{
event.preventDefault();
const nextIndex = selectedSuggestion === null || selectedSuggestion === suggestions.length - 1 ? 0 : selectedSuggestion + 1;
this.setState({
selectedSuggestion: nextIndex
});
break;
}
case TAB:
{
if (this.state.selectedSuggestion !== null) {
this.selectLink(suggestion);
// Announce a link has been selected when tabbing away from the input field.
this.props.speak(__('Link selected.'));
}
break;
}
case ENTER:
{
event.preventDefault();
if (this.state.selectedSuggestion !== null) {
this.selectLink(suggestion);
if (this.props.onSubmit) {
this.props.onSubmit(suggestion, event);
}
} else if (this.props.onSubmit) {
this.props.onSubmit(null, event);
}
break;
}
}
}
selectLink(suggestion) {
this.props.onChange(suggestion.url, suggestion);
this.setState({
selectedSuggestion: null,
showSuggestions: false
});
}
handleOnClick(suggestion) {
this.selectLink(suggestion);
// Move focus to the input field when a link suggestion is clicked.
this.inputRef.current.focus();
}
static getDerivedStateFromProps({
value,
instanceId,
disableSuggestions,
__experimentalShowInitialSuggestions = false
}, {
showSuggestions
}) {
let shouldShowSuggestions = showSuggestions;
const hasValue = value && value.length;
if (!__experimentalShowInitialSuggestions && !hasValue) {
shouldShowSuggestions = false;
}
if (disableSuggestions === true) {
shouldShowSuggestions = false;
}
return {
showSuggestions: shouldShowSuggestions,
suggestionsListboxId: `block-editor-url-input-suggestions-${instanceId}`,
suggestionOptionIdPrefix: `block-editor-url-input-suggestion-${instanceId}`
};
}
render() {
return /*#__PURE__*/_jsxs(_Fragment, {
children: [this.renderControl(), this.renderSuggestions()]
});
}
renderControl() {
const {
label = null,
className,
isFullWidth,
instanceId,
placeholder = __('Paste URL or type to search'),
__experimentalRenderControl: renderControl,
value = '',
hideLabelFromVision = false
} = this.props;
const {
loading,
showSuggestions,
selectedSuggestion,
suggestionsListboxId,
suggestionOptionIdPrefix
} = this.state;
const inputId = `url-input-control-${instanceId}`;
const controlProps = {
id: inputId,
// Passes attribute to label for the for attribute
label,
className: clsx('block-editor-url-input', className, {
'is-full-width': isFullWidth
}),
hideLabelFromVision
};
const inputProps = {
id: inputId,
value,
required: true,
type: 'text',
onChange: this.onChange,
onFocus: this.onFocus,
placeholder,
onKeyDown: this.onKeyDown,
role: 'combobox',
'aria-label': label ? undefined : __('URL'),
// Ensure input always has an accessible label
'aria-expanded': showSuggestions,
'aria-autocomplete': 'list',
'aria-owns': suggestionsListboxId,
'aria-activedescendant': selectedSuggestion !== null ? `${suggestionOptionIdPrefix}-${selectedSuggestion}` : undefined,
ref: this.inputRef,
suffix: this.props.suffix
};
if (renderControl) {
return renderControl(controlProps, inputProps, loading);
}
return /*#__PURE__*/_jsxs(BaseControl, {
__nextHasNoMarginBottom: true,
...controlProps,
children: [/*#__PURE__*/_jsx(InputControl, {
...inputProps,
__next40pxDefaultSize: true
}), loading && /*#__PURE__*/_jsx(Spinner, {})]
});
}
renderSuggestions() {
const {
className,
__experimentalRenderSuggestions: renderSuggestions
} = this.props;
const {
showSuggestions,
suggestions,
suggestionsValue,
selectedSuggestion,
suggestionsListboxId,
suggestionOptionIdPrefix,
loading
} = this.state;
if (!showSuggestions || suggestions.length === 0) {
return null;
}
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 ? true : undefined
};
};
if (isFunction(renderSuggestions)) {
return renderSuggestions({
suggestions,
selectedSuggestion,
suggestionsListProps,
buildSuggestionItemProps,
isLoading: loading,
handleSuggestionClick: this.handleOnClick,
isInitialSuggestions: !suggestionsValue?.length,
currentInputValue: suggestionsValue
});
}
return /*#__PURE__*/_jsx(Popover, {
placement: "bottom",
focusOnMount: false,
children: /*#__PURE__*/_jsx("div", {
...suggestionsListProps,
className: clsx('block-editor-url-input__suggestions', {
[`${className}__suggestions`]: className
}),
children: suggestions.map((suggestion, index) => /*#__PURE__*/_createElement(Button, {
__next40pxDefaultSize: true,
...buildSuggestionItemProps(suggestion, index),
key: suggestion.id,
className: clsx('block-editor-url-input__suggestion', {
'is-selected': index === selectedSuggestion
}),
onClick: () => this.handleOnClick(suggestion)
}, suggestion.title))
})
});
}
}
/**
* @see https://github.com/WordPress/gutenberg/blob/HEAD/packages/block-editor/src/components/url-input/README.md
*/
export default compose(withSafeTimeout, withSpokenMessages, withInstanceId, withSelect((select, props) => {
// If a link suggestions handler is already provided then
// bail.
if (isFunction(props.__experimentalFetchLinkSuggestions)) {
return;
}
const {
getSettings
} = select(blockEditorStore);
return {
__experimentalFetchLinkSuggestions: getSettings().__experimentalFetchLinkSuggestions
};
}))(URLInput);
//# sourceMappingURL=index.js.map