@gechiui/components
Version:
UI components for GeChiUI.
444 lines (392 loc) • 12.1 kB
JavaScript
/**
* External dependencies
*/
import { escapeRegExp, find, deburr } from 'lodash';
/**
* GeChiUI dependencies
*/
import {
renderToString,
useEffect,
useState,
useRef,
} from '@gechiui/element';
import {
ENTER,
ESCAPE,
UP,
DOWN,
LEFT,
RIGHT,
BACKSPACE,
} from '@gechiui/keycodes';
import { __, _n, sprintf } from '@gechiui/i18n';
import {
useInstanceId,
useDebounce,
useMergeRefs,
useRefEffect,
} from '@gechiui/compose';
import {
create,
slice,
insert,
isCollapsed,
getTextContent,
} from '@gechiui/rich-text';
import { speak } from '@gechiui/a11y';
/**
* Internal dependencies
*/
import { getAutoCompleterUI } from './autocompleter-ui';
/**
* A raw completer option.
*
* @typedef {*} CompleterOption
*/
/**
* @callback FnGetOptions
*
* @return {(CompleterOption[]|Promise.<CompleterOption[]>)} The completer options or a promise for them.
*/
/**
* @callback FnGetOptionKeywords
* @param {CompleterOption} option a completer option.
*
* @return {string[]} list of key words to search.
*/
/**
* @callback FnIsOptionDisabled
* @param {CompleterOption} option a completer option.
*
* @return {string[]} whether or not the given option is disabled.
*/
/**
* @callback FnGetOptionLabel
* @param {CompleterOption} option a completer option.
*
* @return {(string|Array.<(string|GCElement)>)} list of react components to render.
*/
/**
* @callback FnAllowContext
* @param {string} before the string before the auto complete trigger and query.
* @param {string} after the string after the autocomplete trigger and query.
*
* @return {boolean} true if the completer can handle.
*/
/**
* @typedef {Object} OptionCompletion
* @property {'insert-at-caret'|'replace'} action the intended placement of the completion.
* @property {OptionCompletionValue} value the completion value.
*/
/**
* A completion value.
*
* @typedef {(string|GCElement|Object)} OptionCompletionValue
*/
/**
* @callback FnGetOptionCompletion
* @param {CompleterOption} value the value of the completer option.
* @param {string} query the text value of the autocomplete query.
*
* @return {(OptionCompletion|OptionCompletionValue)} the completion for the given option. If an
* OptionCompletionValue is returned, the
* completion action defaults to `insert-at-caret`.
*/
/**
* @typedef {Object} GCCompleter
* @property {string} name a way to identify a completer, useful for selective overriding.
* @property {?string} className A class to apply to the popup menu.
* @property {string} triggerPrefix the prefix that will display the menu.
* @property {(CompleterOption[]|FnGetOptions)} options the completer options or a function to get them.
* @property {?FnGetOptionKeywords} getOptionKeywords get the keywords for a given option.
* @property {?FnIsOptionDisabled} isOptionDisabled get whether or not the given option is disabled.
* @property {FnGetOptionLabel} getOptionLabel get the label for a given option.
* @property {?FnAllowContext} allowContext filter the context under which the autocomplete activates.
* @property {FnGetOptionCompletion} getOptionCompletion get the completion associated with a given option.
*/
function useAutocomplete( {
record,
onChange,
onReplace,
completers,
contentRef,
} ) {
const debouncedSpeak = useDebounce( speak, 500 );
const instanceId = useInstanceId( useAutocomplete );
const [ selectedIndex, setSelectedIndex ] = useState( 0 );
const [ filteredOptions, setFilteredOptions ] = useState( [] );
const [ filterValue, setFilterValue ] = useState( '' );
const [ autocompleter, setAutocompleter ] = useState( null );
const [ AutocompleterUI, setAutocompleterUI ] = useState( null );
const [ backspacing, setBackspacing ] = useState( false );
function insertCompletion( replacement ) {
const end = record.start;
const start =
end - autocompleter.triggerPrefix.length - filterValue.length;
const toInsert = create( { html: renderToString( replacement ) } );
onChange( insert( record, toInsert, start, end ) );
}
function select( option ) {
const { getOptionCompletion } = autocompleter || {};
if ( option.isDisabled ) {
return;
}
if ( getOptionCompletion ) {
const completion = getOptionCompletion( option.value, filterValue );
const { action, value } =
undefined === completion.action ||
undefined === completion.value
? { action: 'insert-at-caret', value: completion }
: completion;
if ( 'replace' === action ) {
onReplace( [ value ] );
// When replacing, the component will unmount, so don't reset
// state (below) on an unmounted component.
return;
} else if ( 'insert-at-caret' === action ) {
insertCompletion( value );
}
}
// Reset autocomplete state after insertion rather than before
// so insertion events don't cause the completion menu to redisplay.
reset();
}
function reset() {
setSelectedIndex( 0 );
setFilteredOptions( [] );
setFilterValue( '' );
setAutocompleter( null );
setAutocompleterUI( null );
}
function announce( options ) {
if ( ! debouncedSpeak ) {
return;
}
if ( !! options.length ) {
debouncedSpeak(
sprintf(
/* translators: %d: number of results. */
_n(
'找到%d个结果,使用上下方向键来导航。',
'%d results found, use up and down arrow keys to navigate.',
options.length
),
options.length
),
'assertive'
);
} else {
debouncedSpeak( __( '没有结果。' ), 'assertive' );
}
}
/**
* Load options for an autocompleter.
*
* @param {Array} options
*/
function onChangeOptions( options ) {
setSelectedIndex(
options.length === filteredOptions.length ? selectedIndex : 0
);
setFilteredOptions( options );
announce( options );
}
function handleKeyDown( event ) {
setBackspacing( event.keyCode === BACKSPACE );
if ( ! autocompleter ) {
return;
}
if ( filteredOptions.length === 0 ) {
return;
}
if ( event.defaultPrevented ) {
return;
}
switch ( event.keyCode ) {
case UP:
setSelectedIndex(
( selectedIndex === 0
? filteredOptions.length
: selectedIndex ) - 1
);
break;
case DOWN:
setSelectedIndex(
( selectedIndex + 1 ) % filteredOptions.length
);
break;
case ESCAPE:
setAutocompleter( null );
setAutocompleterUI( null );
event.preventDefault();
break;
case ENTER:
select( filteredOptions[ selectedIndex ] );
break;
case LEFT:
case RIGHT:
reset();
return;
default:
return;
}
// Any handled keycode should prevent original behavior. This relies on
// the early return in the default case.
event.preventDefault();
}
let textContent;
if ( isCollapsed( record ) ) {
textContent = getTextContent( slice( record, 0 ) );
}
useEffect( () => {
if ( ! textContent ) {
reset();
return;
}
const text = deburr( textContent );
const textAfterSelection = getTextContent(
slice( record, undefined, getTextContent( record ).length )
);
const completer = find(
completers,
( { triggerPrefix, allowContext } ) => {
const index = text.lastIndexOf( triggerPrefix );
if ( index === -1 ) {
return false;
}
const textWithoutTrigger = text.slice(
index + triggerPrefix.length
);
const tooDistantFromTrigger = textWithoutTrigger.length > 50; // 50 chars seems to be a good limit.
// This is a final barrier to prevent the effect from completing with
// an extremely long string, which causes the editor to slow-down
// significantly. This could happen, for example, if `matchingWhileBackspacing`
// is true and one of the "words" end up being too long. If that's the case,
// it will be caught by this guard.
if ( tooDistantFromTrigger ) return false;
const mismatch = filteredOptions.length === 0;
const wordsFromTrigger = textWithoutTrigger.split( /\s/ );
// We need to allow the effect to run when not backspacing and if there
// was a mismatch. i.e when typing a trigger + the match string or when
// clicking in an existing trigger word on the page. We do that if we
// detect that we have one word from trigger in the current textual context.
//
// Ex.: "Some text @a" <-- "@a" will be detected as the trigger word and
// allow the effect to run. It will run until there's a mismatch.
const hasOneTriggerWord = wordsFromTrigger.length === 1;
// This is used to allow the effect to run when backspacing and if
// "touching" a word that "belongs" to a trigger. We consider a "trigger
// word" any word up to the limit of 3 from the trigger character.
// Anything beyond that is ignored if there's a mismatch. This allows
// us to "escape" a mismatch when backspacing, but still imposing some
// sane limits.
//
// Ex: "Some text @marcelo sekkkk" <--- "kkkk" caused a mismatch, but
// if the user presses backspace here, it will show the completion popup again.
const matchingWhileBackspacing =
backspacing && textWithoutTrigger.split( /\s/ ).length <= 3;
if (
mismatch &&
! ( matchingWhileBackspacing || hasOneTriggerWord )
) {
return false;
}
if (
allowContext &&
! allowContext( text.slice( 0, index ), textAfterSelection )
) {
return false;
}
if (
/^\s/.test( textWithoutTrigger ) ||
/\s\s+$/.test( textWithoutTrigger )
) {
return false;
}
return /[\u0000-\uFFFF]*$/.test( textWithoutTrigger );
}
);
if ( ! completer ) {
reset();
return;
}
const safeTrigger = escapeRegExp( completer.triggerPrefix );
const match = text
.slice( text.lastIndexOf( completer.triggerPrefix ) )
.match( new RegExp( `${ safeTrigger }([\u0000-\uFFFF]*)$` ) );
const query = match && match[ 1 ];
setAutocompleter( completer );
setAutocompleterUI( () =>
completer !== autocompleter
? getAutoCompleterUI( completer )
: AutocompleterUI
);
setFilterValue( query );
}, [ textContent ] );
const { key: selectedKey = '' } = filteredOptions[ selectedIndex ] || {};
const { className } = autocompleter || {};
const isExpanded = !! autocompleter && filteredOptions.length > 0;
const listBoxId = isExpanded
? `components-autocomplete-listbox-${ instanceId }`
: null;
const activeId = isExpanded
? `components-autocomplete-item-${ instanceId }-${ selectedKey }`
: null;
const hasSelection = record.start !== undefined;
return {
listBoxId,
activeId,
onKeyDown: handleKeyDown,
popover: hasSelection && AutocompleterUI && (
<AutocompleterUI
className={ className }
filterValue={ filterValue }
instanceId={ instanceId }
listBoxId={ listBoxId }
selectedIndex={ selectedIndex }
onChangeOptions={ onChangeOptions }
onSelect={ select }
value={ record }
contentRef={ contentRef }
reset={ reset }
/>
),
};
}
export function useAutocompleteProps( options ) {
const ref = useRef();
const onKeyDownRef = useRef();
const { popover, listBoxId, activeId, onKeyDown } = useAutocomplete( {
...options,
contentRef: ref,
} );
onKeyDownRef.current = onKeyDown;
return {
ref: useMergeRefs( [
ref,
useRefEffect( ( element ) => {
function _onKeyDown( event ) {
onKeyDownRef.current( event );
}
element.addEventListener( 'keydown', _onKeyDown );
return () => {
element.removeEventListener( 'keydown', _onKeyDown );
};
}, [] ),
] ),
children: popover,
'aria-autocomplete': listBoxId ? 'list' : undefined,
'aria-owns': listBoxId,
'aria-activedescendant': activeId,
};
}
export default function Autocomplete( { children, isSelected, ...options } ) {
const { popover, ...props } = useAutocomplete( options );
return (
<>
{ children( props ) }
{ isSelected && popover }
</>
);
}