docbrown/app/javascript/crayons/MentionAutocompleteTextArea/MentionAutocompleteTextArea.jsx

421 lines
14 KiB
JavaScript

import { h, Fragment } from 'preact';
import { useState, useEffect, useRef, useLayoutEffect } from 'preact/hooks';
import { forwardRef } from 'preact/compat';
import PropTypes from 'prop-types';
import {
Combobox,
ComboboxInput,
ComboboxPopover,
ComboboxList,
ComboboxOption,
} from '@reach/combobox';
import { UserListItemContent } from './UserListItemContent';
import {
getMentionWordData,
getCursorXY,
useTextAreaAutoResize,
} from '@utilities/textAreaUtils';
import { useMediaQuery, BREAKPOINTS } from '@components/useMediaQuery';
const MIN_SEARCH_CHARACTERS = 2;
const MAX_RESULTS_DISPLAYED = 6;
// Used to ensure dropdown appears just below search text
const DROPDOWN_VERTICAL_OFFSET = '1.5rem';
/**
* Helper function to merge any additional ref passed to the MentionAutocompleteTextArea with the inputRefs used internally by this component.
*
* @param {Array} refs Array of all references
*/
const mergeInputRefs = (refs) => (value) => {
refs.forEach((ref) => {
if (ref) {
ref.current = value;
}
});
};
/**
* Helper function to copy all styles and attributes from the original textarea to new autocomplete textareas before removing the original node
*
* @param {object} options
* @param {element} options.originalNodeToReplace The DOM element that should be replaced
* @param {element} options.plainTextArea The DOM element to be used in the 'non-autosuggest' state. It will receive all attributes and styles of the original node.
* @param {element} options.comboboxTextArea The DOM element to be used in the 'autosuggest' state. It will receive all attributes and styles of the original node.
*/
const replaceTextArea = ({
originalNodeToReplace,
plainTextArea,
comboboxTextArea,
}) => {
const newNodes = [plainTextArea, comboboxTextArea];
const { attributes } = originalNodeToReplace;
const { cssText } = document.defaultView.getComputedStyle(
originalNodeToReplace,
'',
);
newNodes.forEach((node) => {
// Make sure all attributes are copied to the autocomplete & plain textareas
Object.keys(attributes).forEach((attributeKey) => {
node.setAttribute(
attributes[attributeKey].name,
attributes[attributeKey].value,
);
});
// Make sure all styles are copied to the autocomplete & plain textareas
node.style.cssText = cssText;
// Make sure no transition replays when the new textareas are mounted
node.style.transition = 'none';
// Copy any initial value
node.value = originalNodeToReplace.value;
});
// We need to manually remove the original element, as Preact's diffing algorithm won't replace it in render
originalNodeToReplace.remove();
};
/**
* A component for dynamically searching for users and displaying results in a dropdown.
* This component will optionally replace the textarea passed in props, copying all styles and attributes, and allowing for progressive enhancement.
* The component functions by switching between a normal textarea and a combobox textarea. Both textareas receive the attributes and styles of the replaceElement prop, and only one is presented at a time.
* A ref may be given to the component, which will be forwarded to the new textarea
*
* @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
* @param {object} props.inputProps Any additional props to be attached to the textarea element
*
* @example
* <MentionAutocompleteCombobox
* replaceElement={textAreaRef.current}
* fetchSuggestions={fetchUsersByUsername}
* />
*/
export const MentionAutocompleteTextArea = forwardRef(
(
{ replaceElement, fetchSuggestions, autoResize = false, ...inputProps },
forwardedRef,
) => {
const [textContent, setTextContent] = useState(
inputProps.defaultValue ? inputProps.defaultValue : '',
);
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 [focusable, setFocusable] = useState(false);
const isSmallScreen = useMediaQuery(`(max-width: ${BREAKPOINTS.Small}px)`);
const plainTextAreaRef = useRef(null);
const comboboxRef = useRef(null);
const popoverRef = useRef(null);
const containerRef = useRef(null);
const { setTextArea, setAdditionalElements } = useTextAreaAutoResize();
const {
onChange,
onBlur,
id: inputId,
...autocompleteInputProps
} = inputProps;
useLayoutEffect(() => {
if (autoResize && comboboxRef.current && plainTextAreaRef.current) {
setTextArea(plainTextAreaRef.current);
setAdditionalElements([comboboxRef.current, containerRef.current]);
}
}, [autoResize, setTextArea, setAdditionalElements]);
useEffect(() => {
if (searchTerm.length < MIN_SEARCH_CHARACTERS) {
return;
}
if (cachedSearches[searchTerm]) {
setUsers(cachedSearches[searchTerm]);
return;
}
fetchSuggestions(searchTerm).then(({ result: fetchedUsers }) => {
// If the fetchSuggestion call yields more than the MAX, truncate the results
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([]);
const { selectionStart } = comboboxRef.current;
// Switch back to the plain text area
comboboxRef.current.classList.add('hidden');
plainTextAreaRef.current.classList.remove('hidden');
setCursorPosition(selectionStart + 1);
}
};
document.addEventListener('click', closeOnClickOutsideListener);
return () =>
document.removeEventListener('click', closeOnClickOutsideListener);
}, [searchTerm]);
useLayoutEffect(() => {
const { current: plainTextInput } = plainTextAreaRef;
const { current: combobox } = comboboxRef;
const activeInput = combobox.classList.contains('hidden')
? plainTextInput
: combobox;
if (
focusable ||
document.activeElement === combobox ||
document.activeElement === plainTextInput
) {
// Check if the currently focused element is one of the mention autocomplete's
// inputs. This check is necessary to prevent an issue in iOS browsers only.
// An additional check to see if the component can be focusable
// covers the use case for clicking on elements in the mention autocomplete list.
activeInput.focus({ preventScroll: true });
activeInput.setSelectionRange(cursorPosition, cursorPosition - 1);
setFocusable(true);
}
}, [cursorPosition, focusable]);
const handleTextInputChange = ({ target: { value } }) => {
setTextContent(value);
const isComboboxVisible =
!comboboxRef.current.classList.contains('hidden');
const currentActiveInput = isComboboxVisible
? comboboxRef.current
: plainTextAreaRef.current;
const { isUserMention, indexOfMentionStart } =
getMentionWordData(currentActiveInput);
const { selectionStart } = currentActiveInput;
if (isUserMention) {
// search term begins after the @ character
const searchTermStartPosition = indexOfMentionStart + 1;
const mentionText = value.substring(
searchTermStartPosition,
selectionStart,
);
const { x: cursorX, y } = getCursorXY(
currentActiveInput,
indexOfMentionStart,
);
const textAreaX = currentActiveInput.offsetLeft;
// On small screens always show dropdown at start of textarea
const dropdownX = isSmallScreen ? textAreaX : cursorX;
setDropdownPositionPoints({ x: dropdownX, y });
setSearchTerm(mentionText);
setSelectionInsertIndex(searchTermStartPosition);
if (!isComboboxVisible) {
// This is the start of a fresh search, transfer use from plain textarea to combobox
comboboxRef.current.classList.remove('hidden');
plainTextAreaRef.current.classList.add('hidden');
setCursorPosition(selectionStart + 1);
}
} else if (searchTerm) {
// User has moved away from an in-progress @mention - clear current search
setSearchTerm('');
setAriaHelperText('');
setUsers([]);
if (isComboboxVisible) {
// Search has ended, transfer use from combobox back to plain textarea
comboboxRef.current.classList.add('hidden');
plainTextAreaRef.current.classList.remove('hidden');
setCursorPosition(selectionStart + 1);
}
}
};
const handleComboboxBlur = () => {
// User has left the textarea, exit combobox functionality without refocusing plainTextArea
comboboxRef.current.classList.add('hidden');
plainTextAreaRef.current.classList.remove('hidden');
};
const handleSelect = (username) => {
// Construct the new textArea content with selected username inserted
const textWithSelection = `${textContent.substring(
0,
selectionInsertIndex,
)}${username} ${textContent.substring(
comboboxRef.current.selectionStart,
)}`;
// Clear the current search
setSearchTerm('');
setUsers([]);
setAriaHelperText('');
// Update the text area value
setTextContent(textWithSelection);
// Switch back to the plain text input
comboboxRef.current.classList.add('hidden');
plainTextAreaRef.current.classList.remove('hidden');
// Allow any other attached change event to receive the updated text
onChange?.(textWithSelection);
// Update the cursor to directly after the selection (+2 accounts for the @ sign, and adding a space after the username)
const newCursorPosition = selectionInsertIndex + username.length + 2;
setCursorPosition(newCursorPosition);
};
useLayoutEffect(() => {
const { current: comboboxTextArea } = comboboxRef;
const { current: plainTextArea } = plainTextAreaRef;
if (comboboxTextArea && plainTextArea && replaceElement) {
replaceTextArea({
originalNodeToReplace: replaceElement,
plainTextArea,
comboboxTextArea,
});
plainTextArea.focus({ preventScroll: true });
}
// Initialize the new text areas in the "non-autosuggest" state, hiding the combobox until a search begins
comboboxTextArea.classList.add('hidden');
}, [replaceElement]);
return (
<Fragment>
<div aria-live="polite" class="screen-reader-only">
{ariaHelperText}
</div>
<Combobox
ref={containerRef}
id="combobox-container"
data-testid="autocomplete-wrapper"
onSelect={handleSelect}
className={`crayons-autocomplete${autoResize ? ' h-100' : ''}`}
>
<ComboboxInput
{...autocompleteInputProps}
data-gramm_editor="false"
aria-label="Mention user"
ref={comboboxRef}
value={textContent}
data-mention-autocomplete-active="true"
as="textarea"
autocomplete={false}
onChange={(e) => {
onChange?.(e);
handleTextInputChange(e);
}}
onBlur={(e) => {
onBlur?.(e);
handleComboboxBlur();
}}
/>
<textarea
{...autocompleteInputProps}
data-gramm_editor="false"
id={inputId}
data-mention-autocomplete-active="true"
ref={mergeInputRefs([plainTextAreaRef, forwardedRef])}
onChange={(e) => {
onChange?.(e);
handleTextInputChange(e);
}}
value={textContent}
/>
{searchTerm && (
<ComboboxPopover
ref={popoverRef}
className="crayons-autocomplete__popover absolute"
id="mention-autocomplete-popover"
style={{
top: `calc(${dropdownPositionPoints.y}px + ${DROPDOWN_VERTICAL_OFFSET}`,
left: `${dropdownPositionPoints.x}px`,
}}
>
{users.length > 0 ? (
<ComboboxList>
{users.map((user) => (
<ComboboxOption
key={user.username}
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,
fetchSuggestions: PropTypes.func.isRequired,
autoResize: PropTypes.bool,
};