443 lines
14 KiB
JavaScript
443 lines
14 KiB
JavaScript
import { h } from 'preact';
|
|
import { useRef, useLayoutEffect, useReducer, useEffect } from 'preact/hooks';
|
|
import { forwardRef, createPortal } from 'preact/compat';
|
|
import { UserListItemContent } from './UserListItemContent';
|
|
import { useMediaQuery, BREAKPOINTS } from '@components/useMediaQuery';
|
|
import { isInViewport } from '@utilities/viewport';
|
|
|
|
import {
|
|
useTextAreaAutoResize,
|
|
getAutocompleteWordData,
|
|
getCursorXY,
|
|
} from '@utilities/textAreaUtils';
|
|
|
|
// Used to ensure dropdown appears just below search text
|
|
const DROPDOWN_VERTICAL_OFFSET = '1.5rem';
|
|
const EMPTY_STATE_MESSAGE = 'No results found';
|
|
const MINIMUM_SEARCH_CHARS = 2;
|
|
|
|
const KEYS = {
|
|
UP: 'ArrowUp',
|
|
DOWN: 'ArrowDown',
|
|
ENTER: 'Enter',
|
|
ESCAPE: 'Escape',
|
|
};
|
|
|
|
/**
|
|
* Helper function to copy all styles and attributes from the original textarea to the new autocomplete one
|
|
*
|
|
* @param {object} options
|
|
* @param {element} options.originalTextArea The textarea DOM element that should be replaced/removed
|
|
* @param {element} options.newTextArea The textarea DOM element containing the autocomplete functionality. It will receive all attributes and styles of the original node.
|
|
*/
|
|
const replaceTextArea = ({ originalTextArea, newTextArea }) => {
|
|
const { attributes } = originalTextArea;
|
|
const { cssText } = document.defaultView.getComputedStyle(
|
|
originalTextArea,
|
|
'',
|
|
);
|
|
|
|
// Make sure all attributes are copied over
|
|
Object.keys(attributes).forEach((attributeKey) => {
|
|
newTextArea.setAttribute(
|
|
attributes[attributeKey].name,
|
|
attributes[attributeKey].value,
|
|
);
|
|
});
|
|
|
|
// Make sure all styles are copied over
|
|
newTextArea.style.cssText = cssText;
|
|
// Make sure no transition replays when the new textarea is mounted
|
|
newTextArea.style.transition = 'none';
|
|
// Copy any initial value
|
|
newTextArea.value = originalTextArea.value;
|
|
|
|
// We need to manually remove the original element, as Preact's diffing algorithm won't replace it in render
|
|
originalTextArea.remove();
|
|
};
|
|
|
|
/**
|
|
* Helper function to merge any additional ref passed to the textArea with the ref used internally by this component.
|
|
*
|
|
* @param {Array} refs Array of all references
|
|
*/
|
|
const mergeInputRefs = (refs) => (value) => {
|
|
refs.forEach((ref) => {
|
|
if (ref) {
|
|
ref.current = value;
|
|
}
|
|
});
|
|
};
|
|
|
|
const reducer = (state, action) => {
|
|
switch (action.type) {
|
|
case 'setIsComboboxMode':
|
|
return { ...state, isComboboxMode: action.payload };
|
|
case 'setSuggestions':
|
|
return { ...state, suggestions: action.payload };
|
|
case 'setDropdownPositionPoints':
|
|
return { ...state, dropdownPositionPoints: action.payload };
|
|
case 'setActiveDescendentIndex':
|
|
return { ...state, activeDescendentIndex: action.payload };
|
|
case 'setSuppressPopover':
|
|
return { ...state, suppressPopover: action.payload };
|
|
case 'setEmptyStateMessage':
|
|
return { ...state, emptyStateMessage: action.payload };
|
|
case 'setIgnoreBlur':
|
|
return { ...state, ignoreBlur: action.payload };
|
|
case 'exitComboboxMode':
|
|
return {
|
|
...state,
|
|
suggestions: [],
|
|
activeDescendentIndex: null,
|
|
isComboboxMode: false,
|
|
};
|
|
|
|
default:
|
|
return state;
|
|
}
|
|
};
|
|
|
|
/**
|
|
* Renders a textarea with enhanced autocomplete functionality.
|
|
* Autocomplete searching will start when user types the given trigger character, with suggestions fetched via fetchSuggestions callback.
|
|
* Can optionally replace an existing textarea, passed as the replaceElement prop.
|
|
*/
|
|
export const AutocompleteTriggerTextArea = forwardRef(
|
|
(
|
|
{
|
|
id,
|
|
triggerCharacter,
|
|
autoResize = false,
|
|
onChange,
|
|
onBlur,
|
|
fetchSuggestions,
|
|
searchInstructionsMessage,
|
|
maxSuggestions,
|
|
replaceElement,
|
|
...inputProps
|
|
},
|
|
forwardedRef,
|
|
) => {
|
|
const [state, dispatch] = useReducer(reducer, {
|
|
isComboboxMode: false,
|
|
suggestions: [],
|
|
dropdownPositionPoints: {
|
|
x: 0,
|
|
y: 0,
|
|
},
|
|
activeDescendentIndex: null,
|
|
suppressPopover: false,
|
|
emptyStateMessage: searchInstructionsMessage,
|
|
ignoreBlur: false,
|
|
});
|
|
|
|
const {
|
|
isComboboxMode,
|
|
suggestions,
|
|
dropdownPositionPoints,
|
|
activeDescendentIndex,
|
|
suppressPopover,
|
|
emptyStateMessage,
|
|
ignoreBlur,
|
|
} = state;
|
|
|
|
const isSmallScreen = useMediaQuery(`(max-width: ${BREAKPOINTS.Small}px)`);
|
|
|
|
const inputRef = useRef(null);
|
|
const popoverRef = useRef(null);
|
|
const wrapperRef = useRef(null);
|
|
|
|
const { setTextArea, setAdditionalElements } = useTextAreaAutoResize();
|
|
|
|
useEffect(() => {
|
|
if (activeDescendentIndex !== null) {
|
|
const { current: popover } = popoverRef;
|
|
const activeItem = popover?.querySelector('[aria-selected="true"]');
|
|
if (!popover || !activeItem) {
|
|
return;
|
|
}
|
|
|
|
if (!isInViewport({ element: activeItem })) {
|
|
activeItem.scrollIntoView(false);
|
|
}
|
|
}
|
|
}, [activeDescendentIndex]);
|
|
|
|
useLayoutEffect(() => {
|
|
if (autoResize && inputRef.current) {
|
|
setTextArea(inputRef.current);
|
|
setAdditionalElements([wrapperRef.current]);
|
|
}
|
|
}, [autoResize, setTextArea, setAdditionalElements]);
|
|
|
|
useLayoutEffect(() => {
|
|
const { current: enhancedTextArea } = inputRef;
|
|
|
|
if (enhancedTextArea && replaceElement) {
|
|
replaceTextArea({
|
|
originalTextArea: replaceElement,
|
|
newTextArea: enhancedTextArea,
|
|
});
|
|
enhancedTextArea.focus({ preventScroll: true });
|
|
}
|
|
}, [replaceElement]);
|
|
|
|
const handleInputChange = () => {
|
|
const { current: currentInput } = inputRef;
|
|
|
|
const {
|
|
isTriggered: isSearching,
|
|
indexOfAutocompleteStart: indexOfSearchStart,
|
|
} = getAutocompleteWordData({
|
|
textArea: currentInput,
|
|
triggerCharacter,
|
|
});
|
|
|
|
dispatch({ type: 'setIsComboboxMode', payload: isSearching });
|
|
|
|
if (!isSearching) {
|
|
dispatch({ type: 'setSuggestions', payload: [] });
|
|
return;
|
|
}
|
|
|
|
// Fetch suggestions
|
|
const { selectionStart, value: currentValue } = currentInput;
|
|
|
|
// Search term begins after the triggerCharacter
|
|
const searchTermStartPosition = indexOfSearchStart + 1;
|
|
|
|
const searchTerm = currentValue.substring(
|
|
searchTermStartPosition,
|
|
selectionStart,
|
|
);
|
|
|
|
if (searchTerm.length >= MINIMUM_SEARCH_CHARS) {
|
|
fetchSuggestions(searchTerm).then((suggestions) => {
|
|
if (maxSuggestions && suggestions.length > maxSuggestions) {
|
|
dispatch({
|
|
type: 'setSuggestions',
|
|
payload: suggestions.slice(0, maxSuggestions),
|
|
});
|
|
return;
|
|
}
|
|
dispatch({ type: 'setSuggestions', payload: suggestions });
|
|
});
|
|
|
|
dispatch({
|
|
type: 'setEmptyStateMessage',
|
|
payload: EMPTY_STATE_MESSAGE,
|
|
});
|
|
} else {
|
|
dispatch({
|
|
type: 'setEmptyStateMessage',
|
|
payload: searchInstructionsMessage,
|
|
});
|
|
}
|
|
|
|
// Ensure dropdown is properly positioned
|
|
const { x: cursorX, y } = getCursorXY({
|
|
input: currentInput,
|
|
selectionPoint: indexOfSearchStart,
|
|
});
|
|
const textAreaX = currentInput.offsetLeft;
|
|
|
|
// On small screens always show dropdown at start of textarea
|
|
const dropdownX = isSmallScreen ? textAreaX : cursorX;
|
|
|
|
dispatch({
|
|
type: 'setDropdownPositionPoints',
|
|
payload: { x: dropdownX, y },
|
|
});
|
|
};
|
|
|
|
const handleKeyDown = (e) => {
|
|
// If we are not in combobox mode, ignore
|
|
if (!isComboboxMode) {
|
|
return;
|
|
}
|
|
|
|
switch (e.key) {
|
|
case KEYS.DOWN:
|
|
e.preventDefault();
|
|
|
|
if (
|
|
activeDescendentIndex !== null &&
|
|
activeDescendentIndex < suggestions.length - 1
|
|
) {
|
|
dispatch({
|
|
type: 'setActiveDescendentIndex',
|
|
payload: activeDescendentIndex + 1,
|
|
});
|
|
} else {
|
|
dispatch({ type: 'setActiveDescendentIndex', payload: 0 });
|
|
}
|
|
break;
|
|
case KEYS.UP:
|
|
e.preventDefault();
|
|
dispatch({
|
|
type: 'setActiveDescendentIndex',
|
|
payload:
|
|
activeDescendentIndex >= 1
|
|
? activeDescendentIndex - 1
|
|
: suggestions.length - 1,
|
|
});
|
|
|
|
break;
|
|
case KEYS.ENTER:
|
|
e.preventDefault();
|
|
if (activeDescendentIndex !== null) {
|
|
selectSuggestion(suggestions[activeDescendentIndex]);
|
|
}
|
|
break;
|
|
case KEYS.ESCAPE:
|
|
e.preventDefault();
|
|
// Temporarily close the popover until next keypress
|
|
dispatch({ type: 'setSuppressPopover', payload: true });
|
|
|
|
return;
|
|
}
|
|
dispatch({ type: 'setSuppressPopover', payload: false });
|
|
};
|
|
|
|
// If a user clicks away from an in-progress search, we can assume they no longer wish to keep searching
|
|
const handleTextAreaClicked = () => dispatch({ type: 'exitComboboxMode' });
|
|
|
|
// The textarea blurs when an option is clicked from the dropdown suggestions, in which case we don't want to
|
|
// trigger the behaviour for the user having left the textarea completely, hence the `ignoreBlur` boolean.
|
|
const handleBlur = () => {
|
|
if (!ignoreBlur) {
|
|
dispatch({ type: 'exitComboboxMode' });
|
|
return;
|
|
}
|
|
dispatch({ type: 'setIgnoreBlur', payload: false });
|
|
};
|
|
|
|
const selectSuggestion = (suggestion) => {
|
|
const { current: currentInput } = inputRef;
|
|
const { indexOfAutocompleteStart: indexOfSearchStart } =
|
|
getAutocompleteWordData({
|
|
textArea: currentInput,
|
|
triggerCharacter,
|
|
});
|
|
|
|
const currentSearchTerm = currentInput.value.substring(
|
|
indexOfSearchStart,
|
|
currentInput.selectionStart,
|
|
);
|
|
|
|
// We try to update the textArea with document.execCommand (so that the change is added to undo queue),
|
|
// which requires the contentEditable attribute to be true. The value is later toggled back to 'false'.
|
|
currentInput.contentEditable = 'true';
|
|
// Input blurs when user clicks an option with the mouse
|
|
currentInput.focus();
|
|
currentInput.setSelectionRange(
|
|
indexOfSearchStart,
|
|
indexOfSearchStart + currentSearchTerm.length,
|
|
);
|
|
|
|
try {
|
|
document.execCommand(
|
|
'insertText',
|
|
false,
|
|
`${triggerCharacter}${suggestion.value} `,
|
|
);
|
|
} catch {
|
|
// In the event of any error using execCommand, we make sure the text area updates (but undo queue will not)
|
|
const { value: currentValue } = currentInput;
|
|
const newTextAreaValue = `${currentValue.substring(
|
|
0,
|
|
indexOfSearchStart,
|
|
)}${triggerCharacter}${suggestion.value}${currentValue.substring(
|
|
indexOfSearchStart + currentSearchTerm.length,
|
|
)} `;
|
|
currentInput.value = newTextAreaValue;
|
|
}
|
|
|
|
currentInput.contentEditable = 'false';
|
|
|
|
// Clear suggestions
|
|
dispatch({ type: 'exitComboboxMode' });
|
|
};
|
|
|
|
const comboboxProps = isComboboxMode
|
|
? {
|
|
role: 'combobox',
|
|
'aria-haspopup': 'listbox',
|
|
'aria-expanded': isComboboxMode,
|
|
'aria-owns': `${id}-listbox`,
|
|
'aria-activedescendant': `${id}-suggestion-${activeDescendentIndex}`,
|
|
}
|
|
: {};
|
|
|
|
return (
|
|
<div
|
|
ref={wrapperRef}
|
|
className={`c-autocomplete ${autoResize ? ' h-100' : ''}`}
|
|
data-testid="autocomplete-wrapper"
|
|
>
|
|
{/* We use an assertive live region to alert screen reader users typing will now result in a search */}
|
|
<span className="screen-reader-only" aria-live="assertive">
|
|
{isComboboxMode ? searchInstructionsMessage : ''}
|
|
</span>
|
|
|
|
<textarea
|
|
{...inputProps}
|
|
{...comboboxProps}
|
|
id={id}
|
|
ref={mergeInputRefs([inputRef, forwardedRef])}
|
|
onChange={(e) => {
|
|
onChange?.(e);
|
|
handleInputChange(e);
|
|
}}
|
|
onBlur={(e) => {
|
|
onBlur?.(e);
|
|
handleBlur();
|
|
}}
|
|
onKeyDown={handleKeyDown}
|
|
onClick={handleTextAreaClicked}
|
|
/>
|
|
{isComboboxMode && !suppressPopover
|
|
? createPortal(
|
|
<div
|
|
ref={popoverRef}
|
|
className="c-autocomplete__popover absolute"
|
|
id={`${id}-autocomplete-popover`}
|
|
style={{
|
|
top: `calc(${dropdownPositionPoints.y}px + ${DROPDOWN_VERTICAL_OFFSET}`,
|
|
left: `${dropdownPositionPoints.x}px`,
|
|
}}
|
|
>
|
|
{suggestions && suggestions.length > 0 ? (
|
|
<ul className="list-none" role="listbox" id={`${id}-listbox`}>
|
|
{suggestions.map((suggestion, index) => (
|
|
// Disabled as the key handler is attached to the textarea
|
|
// eslint-disable-next-line jsx-a11y/click-events-have-key-events
|
|
<li
|
|
key={`${id}-suggestion-${index}`}
|
|
id={`${id}-suggestion-${index}`}
|
|
role="option"
|
|
aria-selected={index === activeDescendentIndex}
|
|
className="c-autocomplete__option flex items-center"
|
|
onClick={() => selectSuggestion(suggestion)}
|
|
onMouseDown={() =>
|
|
dispatch({ type: 'setIgnoreBlur', payload: true })
|
|
}
|
|
>
|
|
<UserListItemContent {...suggestion} />
|
|
</li>
|
|
))}
|
|
</ul>
|
|
) : (
|
|
<span className="c-autocomplete__empty">
|
|
{emptyStateMessage}
|
|
</span>
|
|
)}
|
|
</div>,
|
|
document.querySelector('body'),
|
|
)
|
|
: null}
|
|
</div>
|
|
);
|
|
},
|
|
);
|