docbrown/app/javascript/crayons/MentionAutocompleteTextArea/MentionAutocompleteTextArea.jsx
Suzanne Aitchison e3c97d18d6
Update storybook mention autocomplete component to take over whole textarea (#13044)
* Revert "Revert "Update storybook autocomplete component (#12980)" (#13027)"

This reverts commit f393dd2bbc.

* create helper function to replace node, add comments

* minor refactors
2021-03-18 15:04:59 +00:00

274 lines
8.4 KiB
JavaScript

import { h, Fragment } from 'preact';
import { useState, useEffect, useRef, useLayoutEffect } from 'preact/hooks';
import PropTypes from 'prop-types';
import {
Combobox,
ComboboxInput,
ComboboxPopover,
ComboboxList,
ComboboxOption,
} from '@reach/combobox';
import '@reach/combobox/styles.css';
import { getMentionWordData, getCursorXY } from '@utilities/textAreaUtils';
import { useMediaQuery, BREAKPOINTS } from '@components/useMediaQuery';
const MIN_SEARCH_CHARACTERS = 2;
const MAX_RESULTS_DISPLAYED = 6;
/**
* Helper function to copy all styles and attributes from the original textarea to new autocomplete textarea before removing the original node
*
* @param {element} originalNodeToReplace The DOM element that should be replaced
* @param {element} newNode The DOM element that will receive all attributes and styles of the original node.
*/
const replaceTextArea = (originalNodeToReplace, newNode) => {
// Make sure all attributes are copied to the autocomplete textarea
const attributes = originalNodeToReplace.attributes;
Object.keys(attributes).forEach((attributeKey) => {
newNode.setAttribute(
attributes[attributeKey].name,
attributes[attributeKey].value,
);
});
// Make sure all styles are copied to the autocomplete textarea
newNode.style.cssText = document.defaultView.getComputedStyle(
originalNodeToReplace,
'',
).cssText;
// We need to manually remove the element, as Preact's diffing algorithm won't replace it in render
originalNodeToReplace.remove();
newNode.focus();
};
const UserListItemContent = ({ user }) => {
return (
<Fragment>
<span className="crayons-avatar crayons-avatar--l mr-2 shrink-0">
<img
src={user.profile_image_90}
alt=""
className="crayons-avatar__image "
/>
</span>
<div>
<p className="crayons-autocomplete__name">{user.name}</p>
<p className="crayons-autocomplete__username">{`@${user.username}`}</p>
</div>
</Fragment>
);
};
/**
* A component for dynamically searching for users and displaying results in a dropdown.
* This component will replace the textarea passed in props, copying all styles and attributes, and allowing for progressive enhancement
*
* @param {object} props
* @param {element} props.replaceElement The textarea DOM element that should be replaced
* @param {function} props.fetchSuggestions The async call to use for the search
*
* @example
* <MentionAutocompleteCombobox
* replaceElement={textAreaRef.current}
* fetchSuggestions={fetchUsersByUsername}
* />
*/
export const MentionAutocompleteTextArea = ({
replaceElement,
fetchSuggestions,
}) => {
const [textContent, setTextContent] = useState('');
const [searchTerm, setSearchTerm] = useState('');
const [cachedSearches, setCachedSearches] = useState({});
const [dropdownPositionPoints, setDropdownPositionPoints] = useState({
x: 0,
y: 0,
});
const [selectionInsertIndex, setSelectionInsertIndex] = useState(0);
const [users, setUsers] = useState([]);
const [cursorPosition, setCursorPosition] = useState(null);
const [ariaHelperText, setAriaHelperText] = useState('');
const isSmallScreen = useMediaQuery(`(max-width: ${BREAKPOINTS.Small}px)`);
const inputRef = useRef(null);
const popoverRef = useRef(null);
useEffect(async () => {
if (searchTerm.length < MIN_SEARCH_CHARACTERS) {
return;
}
if (cachedSearches[searchTerm]) {
setUsers(cachedSearches[searchTerm]);
return;
}
const { result: fetchedUsers } = await fetchSuggestions(searchTerm);
const resultLength = Math.min(fetchedUsers.length, MAX_RESULTS_DISPLAYED);
const results = fetchedUsers.slice(0, resultLength);
setCachedSearches({
...cachedSearches,
[searchTerm]: results,
});
setUsers(results);
// Let screen reader users know a list has populated
if (!ariaHelperText && fetchedUsers.length > 0) {
setAriaHelperText(`Mention user, ${fetchedUsers.length} results found`);
}
}, [searchTerm, fetchSuggestions, cachedSearches, ariaHelperText]);
useLayoutEffect(() => {
const popover = popoverRef.current;
if (!popover) {
return;
}
const closeOnClickOutsideListener = (event) => {
if (!popover.contains(event.target)) {
// User clicked outside, reset to not searching state
setSearchTerm('');
setAriaHelperText('');
setUsers([]);
}
};
document.addEventListener('click', closeOnClickOutsideListener);
return () =>
document.removeEventListener('click', closeOnClickOutsideListener);
}, [searchTerm]);
useLayoutEffect(() => {
const { current: input } = inputRef;
input.focus();
input.setSelectionRange(cursorPosition, cursorPosition - 1);
}, [cursorPosition]);
const handleTextInputChange = ({ target: { value } }) => {
setTextContent(value);
const { isUserMention, indexOfMentionStart } = getMentionWordData(
inputRef.current,
);
const { selectionStart } = inputRef.current;
if (isUserMention) {
// search term begins after the @ character
const searchTermStartPosition = indexOfMentionStart + 1;
const mentionText = value.substring(
searchTermStartPosition,
selectionStart,
);
const { x: cursorX, y } = getCursorXY(
inputRef.current,
indexOfMentionStart,
);
const textAreaX = inputRef.current.offsetLeft;
// On small screens always show dropdown at start of textarea
const dropdownX = isSmallScreen ? textAreaX : cursorX;
setDropdownPositionPoints({ x: dropdownX, y });
setSearchTerm(mentionText);
setSelectionInsertIndex(searchTermStartPosition);
} else if (searchTerm) {
// User has moved away from an in-progress @mention - clear current search
setSearchTerm('');
setAriaHelperText('');
setUsers([]);
}
};
const handleSelect = (username) => {
// Construct the new textArea content with selected username inserted
const textWithSelection = `${textContent.substring(
0,
selectionInsertIndex,
)}${username}${textContent.substring(inputRef.current.selectionStart)}`;
// Clear the current search
setSearchTerm('');
setUsers([]);
setAriaHelperText('');
// Update the text area value
setTextContent(textWithSelection);
// Update the cursor to directly after the selection
const newCursorPosition = selectionInsertIndex + username.length + 1;
setCursorPosition(newCursorPosition);
};
useLayoutEffect(() => {
if (inputRef.current) {
// Replace the whole textarea passed in props with the autocomplete textarea
replaceTextArea(replaceElement, inputRef.current);
}
}, [replaceElement]);
return (
<Fragment>
<div aria-live="polite" class="screen-reader-only">
{ariaHelperText}
</div>
<Combobox
id="combobox-container"
onSelect={handleSelect}
className="crayons-autocomplete"
>
<ComboboxInput
ref={inputRef}
value={textContent}
data-mention-autocomplete-active="true"
as="textarea"
autocomplete={false}
onChange={handleTextInputChange}
/>
{searchTerm && (
<ComboboxPopover
ref={popoverRef}
className="crayons-autocomplete__popover absolute"
id="mention-autocomplete-popover"
style={{
top: `calc(${dropdownPositionPoints.y}px + 1.5rem)`,
left: `${dropdownPositionPoints.x}px`,
}}
>
{users.length > 0 ? (
<ComboboxList>
{users.map((user) => (
<ComboboxOption
value={user.username}
className="crayons-autocomplete__option flex items-center"
>
<UserListItemContent user={user} />
</ComboboxOption>
))}
</ComboboxList>
) : (
<span className="crayons-autocomplete__empty">
{searchTerm.length >= MIN_SEARCH_CHARACTERS
? 'No results found'
: 'Type to search for a user'}
</span>
)}
</ComboboxPopover>
)}
</Combobox>
</Fragment>
);
};
MentionAutocompleteTextArea.propTypes = {
replaceElement: PropTypes.node.isRequired,
fetchSuggestions: PropTypes.func.isRequired,
};