diff --git a/app/assets/stylesheets/base/helpers.scss b/app/assets/stylesheets/base/helpers.scss
index abc7db77e..f3a664adb 100644
--- a/app/assets/stylesheets/base/helpers.scss
+++ b/app/assets/stylesheets/base/helpers.scss
@@ -36,6 +36,15 @@
border-top: var(--su-7) solid var(--accent-brand);
}
+.screen-reader-only {
+ position: absolute;
+ left: -10000px;
+ top: auto;
+ width: 1px;
+ height: 1px;
+ overflow: hidden;
+}
+
@media print {
.print-hidden {
display: none;
diff --git a/app/assets/stylesheets/components/autocomplete.scss b/app/assets/stylesheets/components/autocomplete.scss
index e3d3323c1..933a4afe3 100644
--- a/app/assets/stylesheets/components/autocomplete.scss
+++ b/app/assets/stylesheets/components/autocomplete.scss
@@ -2,8 +2,9 @@
.crayons-autocomplete {
&__popover {
- min-width: 300px;
+ max-width: 300px;
padding: var(--su-1);
+ z-index: var(--z-elevate);
&[data-reach-popover] {
@include generate-box(
diff --git a/app/javascript/crayons/MentionAutocomplete/MentionAutocomplete.jsx b/app/javascript/crayons/MentionAutocomplete/MentionAutocomplete.jsx
deleted file mode 100644
index 3c541c3e8..000000000
--- a/app/javascript/crayons/MentionAutocomplete/MentionAutocomplete.jsx
+++ /dev/null
@@ -1,141 +0,0 @@
-import { h, render } from 'preact';
-import { useState, useEffect, useCallback } from 'preact/hooks';
-import { getCursorXY } from '@utilities/textAreaUtils';
-import { useMediaQuery, BREAKPOINTS } from '@components/useMediaQuery';
-
-/**
- * A component which listens for an '@' keypress, and encompasses the MentionAutocomplete functionality.
- * When an autocomplete suggestion is selected, it is inserted into the textarea.
- *
- * @param {object} props
- * @param {object} props.textAreaRef A reference to the text area where an '@' mention can take place
- * @param {function} props.fetchSuggestions The callback to search for suggestions
- *
- * @example
- *
- *
- *
- *
- */
-export const MentionAutocomplete = ({ textAreaRef, fetchSuggestions }) => {
- const [isAutocompleteActive, setIsAutocompleteActive] = useState(false);
- const [cursorPlacementData, setCursorPlacementData] = useState({});
-
- const isSmallScreen = useMediaQuery(`(max-width: ${BREAKPOINTS.Small}px)`);
-
- const handleSearchTermChange = useCallback(
- (searchTerm) => {
- const { textBefore, textAfter } = cursorPlacementData;
-
- const newValue = `${textBefore}${searchTerm}${textAfter}`;
- textAreaRef.current.value = newValue;
- },
- [cursorPlacementData, textAreaRef],
- );
-
- const handleSelection = useCallback(
- (selection) => {
- const { textBefore, textAfter } = cursorPlacementData;
- const newValueUntilEndOfSearch = `${textBefore}${selection}`;
- textAreaRef.current.value = `${newValueUntilEndOfSearch}${textAfter}`;
-
- const nextCursorPosition = newValueUntilEndOfSearch.length;
- setIsAutocompleteActive(false);
- textAreaRef.current.focus();
- textAreaRef.current.setSelectionRange(
- nextCursorPosition,
- nextCursorPosition,
- );
- },
- [cursorPlacementData, textAreaRef],
- );
-
- useEffect(() => {
- const keyEventListener = ({ key }) => {
- if (key === '@') {
- if (!shouldKeyPressTriggerSearch(textAreaRef.current)) {
- return;
- }
-
- const textAreaX = textAreaRef.current.offsetLeft;
- const cursorCoords = getCursorXY(
- textAreaRef.current,
- textAreaRef.current.selectionStart,
- );
-
- const coords = {
- y: cursorCoords.y,
- x: isSmallScreen ? textAreaX : cursorCoords.x,
- };
-
- setCursorPlacementData({
- ...coords,
- textBefore: textAreaRef.current.value.substring(
- 0,
- textAreaRef.current.selectionStart,
- ),
- textAfter: textAreaRef.current.value.substring(
- textAreaRef.current.selectionStart,
- ),
- });
- setIsAutocompleteActive(true);
- }
- };
-
- const textArea = textAreaRef.current;
-
- if (textArea) {
- textArea.addEventListener('keydown', keyEventListener);
- return () => textArea.removeEventListener('keydown', keyEventListener);
- }
- }, [textAreaRef, isSmallScreen]);
-
- useEffect(() => {
- const container = document.getElementById('mention-autocomplete-container');
- if (!container) {
- return;
- }
- if (isAutocompleteActive) {
- import('./MentionAutocompleteCombobox').then(
- ({ MentionAutocompleteCombobox }) => {
- render(
- ,
- container,
- );
- },
- );
- } else {
- render(null, container);
- }
- }, [
- cursorPlacementData,
- fetchSuggestions,
- handleSearchTermChange,
- handleSelection,
- isAutocompleteActive,
- ]);
-
- return ;
-};
-
-const VALID_PREVIOUS_CHAR_REGEX = new RegExp(/[^A-Za-z0-9]/);
-const shouldKeyPressTriggerSearch = (textArea) => {
- const { selectionStart, value: valueBeforeKeystroke } = textArea;
-
- const previousCharacter = valueBeforeKeystroke.charAt(selectionStart - 1);
-
- return (
- previousCharacter === '' ||
- VALID_PREVIOUS_CHAR_REGEX.test(previousCharacter)
- );
-};
diff --git a/app/javascript/crayons/MentionAutocomplete/MentionAutocompleteCombobox.jsx b/app/javascript/crayons/MentionAutocomplete/MentionAutocompleteCombobox.jsx
deleted file mode 100644
index 1319abc7c..000000000
--- a/app/javascript/crayons/MentionAutocomplete/MentionAutocompleteCombobox.jsx
+++ /dev/null
@@ -1,177 +0,0 @@
-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';
-
-const UserListItemContent = ({ user }) => {
- return (
-
-
-
-
-
-
-
{user.name}
-
{`@${user.username}`}
-
-
- );
-};
-
-/**
- * A component for dynamically searching for users and displaying results in a dropdown.
- * This component should be mounted when a user has started typing a mention with the '@' symbol, and will be positioned at the given coordinates.
- *
- * @param {object} props
- * @param {string} props.startText The initial search term to use
- * @param {function} props.onSelect Callback function for using the selected user
- * @param {function} props.fetchSuggestions The async call to use for the search
- * @param {object} props.placementCoords The x/y coordinates for placement of the popover
- * @param {function} props.onSearchTermChange A callback for each time the searchTerm changes
- *
- * @example
- *
- */
-export const MentionAutocompleteCombobox = ({
- onSelect,
- fetchSuggestions,
- placementCoords,
- onSearchTermChange,
-}) => {
- const [searchTerm, setSearchTerm] = useState('@');
- const [cachedSearches, setCachedSearches] = useState({});
- const [users, setUsers] = useState([]);
-
- const inputRef = useRef(null);
-
- useEffect(() => {
- if (searchTerm.length >= 3) {
- // Remove the '@' symbol for search
- const trimmedSearchTerm = searchTerm.substring(1);
-
- if (cachedSearches[trimmedSearchTerm]) {
- setUsers(cachedSearches[trimmedSearchTerm]);
- return;
- }
-
- fetchSuggestions(trimmedSearchTerm).then((fetchedUsers) => {
- setCachedSearches({
- ...cachedSearches,
- [trimmedSearchTerm]: fetchedUsers,
- });
- setUsers(fetchedUsers);
- });
- }
- }, [searchTerm, fetchSuggestions, cachedSearches]);
-
- useEffect(() => {
- inputRef.current.focus();
- }, [inputRef]);
-
- useLayoutEffect(() => {
- const popover = document.getElementById('mention-autocomplete-popover');
- if (!popover) {
- return;
- }
-
- const closeOnClickOutsideListener = (event) => {
- if (!popover.contains(event.target)) {
- // User clicked outside, exit with current search term
- onSelect(searchTerm);
- }
- };
-
- document.addEventListener('click', closeOnClickOutsideListener);
-
- return () =>
- document.removeEventListener('click', closeOnClickOutsideListener);
- }, [searchTerm, onSelect]);
-
- const handleSearchTermChange = (event) => {
- const {
- target: { value },
- } = event;
-
- if (value.charAt(value.length - 1) === ' ' || value === '') {
- // User has spaced away from a complete word or deleted everything - finish the autocomplete
- onSelect(value);
- return;
- }
- setSearchTerm(value);
- onSearchTermChange(value);
- };
-
- return (
- onSelect(`@${item}`)}
- className="crayons-autocomplete"
- >
-
-
- {users.length > 0 ? (
-
- {users.map((user) => (
-
-
-
- ))}
-
- ) : (
-
- {searchTerm.length >= 2
- ? 'No results found'
- : 'Type to search for a user'}
-
- )}
-
-
- );
-};
-
-MentionAutocompleteCombobox.propTypes = {
- onSelect: PropTypes.func.isRequired,
- fetchSuggestions: PropTypes.func.isRequired,
- placementCoords: PropTypes.shape({
- x: PropTypes.number,
- y: PropTypes.number,
- }).isRequired,
- onSearchTermChange: PropTypes.func.isRequired,
-};
diff --git a/app/javascript/crayons/MentionAutocomplete/__stories__/MentionAutocomplete.stories.jsx b/app/javascript/crayons/MentionAutocomplete/__stories__/MentionAutocomplete.stories.jsx
deleted file mode 100644
index e2034fb07..000000000
--- a/app/javascript/crayons/MentionAutocomplete/__stories__/MentionAutocomplete.stories.jsx
+++ /dev/null
@@ -1,75 +0,0 @@
-import { h, createRef, Fragment } from 'preact';
-import notes from './mention-autocomplete.md';
-import { MentionAutocomplete } from '@crayons/MentionAutocomplete';
-
-export default {
- title: 'Components/MentionAutocomplete',
- parameters: { notes },
-};
-
-function fetchUsers(searchTerm) {
- const exampleApiResult = {
- result: [
- {
- username: 'user_one',
- name: 'User One First Name Last Name',
- profile_image_90: '/images/apple-icon.png',
- },
- {
- username: 'user_two',
- name: 'User Two',
- profile_image_90: '/images/apple-icon.png',
- },
- {
- username: 'user_three',
- name: 'User Three',
- profile_image_90: '/images/apple-icon.png',
- },
- {
- username: 'user_four',
- name: 'User Four',
- profile_image_90: '/images/apple-icon.png',
- },
- {
- username: 'user_five',
- name: 'User Five',
- profile_image_90: '/images/apple-icon.png',
- },
- {
- username: 'user_six',
- name: 'User Six First Name Last Name Longer',
- profile_image_90: '/images/apple-icon.png',
- },
- ],
- };
-
- return Promise.resolve(
- exampleApiResult.result.filter((user) =>
- user.username.includes(searchTerm),
- ),
- );
-}
-
-export const Default = () => {
- const textAreaRef = createRef(null);
- return (
-
-
-
-
-
- );
-};
-
-Default.story = {
- name: 'default',
-};
diff --git a/app/javascript/crayons/MentionAutocomplete/__stories__/mention-autocomplete.md b/app/javascript/crayons/MentionAutocomplete/__stories__/mention-autocomplete.md
deleted file mode 100644
index 76e7c8c57..000000000
--- a/app/javascript/crayons/MentionAutocomplete/__stories__/mention-autocomplete.md
+++ /dev/null
@@ -1,14 +0,0 @@
-## Mention autocomplete
-
-The mention autocomplete component uses the [Reach UI Combobox](https://reach.tech/combobox/) under the hood. It can be used for searching for users by username by passing in a reference to the relevant text area.
-
-The autocomplete will begin fetching suggestions once a user has type '@' plus at least two characters. A user's selection is confirmed when they either:
-
-- Click on a search option
-- Hit enter on a search option
-- Click away from the dropdown
-- Enter space to move away from the autocomplete
-
-### Mention autocomplete accessibility
-
-The component works by switching focus to an invisible combobox input once the user types @. Focus is sent back to the original textarea when selection is completed. The underlying [Reach UI Combobox](https://reach.tech/combobox/) behavior conforms to the [WAI_ARIA guidelines on comboboxes](https://www.w3.org/TR/wai-aria-practices-1.2/#combobox).
diff --git a/app/javascript/crayons/MentionAutocomplete/__tests__/MentionAutocomplete.test.jsx b/app/javascript/crayons/MentionAutocomplete/__tests__/MentionAutocomplete.test.jsx
deleted file mode 100644
index 9052d3569..000000000
--- a/app/javascript/crayons/MentionAutocomplete/__tests__/MentionAutocomplete.test.jsx
+++ /dev/null
@@ -1,38 +0,0 @@
-import { h, Fragment, createRef } from 'preact';
-import { render } from '@testing-library/preact';
-import '@testing-library/jest-dom';
-import { axe } from 'jest-axe';
-import { MentionAutocomplete } from '../MentionAutocomplete';
-
-describe('', () => {
- const testTextAreaRef = createRef(null);
- const testTextArea = (
-
- );
-
- const mockFetchSuggestions = jest.fn();
-
- it('should have no a11y violations when rendered', async () => {
- global.window.matchMedia = jest.fn((query) => {
- return {
- matches: false,
- media: query,
- addListener: jest.fn(),
- removeListener: jest.fn(),
- };
- });
-
- const { container } = render(
-
- {testTextArea}
-
- ,
- );
-
- const results = await axe(container);
- expect(results).toHaveNoViolations();
- });
-});
diff --git a/app/javascript/crayons/MentionAutocomplete/__tests__/MentionAutocompleteCombobox.test.jsx b/app/javascript/crayons/MentionAutocomplete/__tests__/MentionAutocompleteCombobox.test.jsx
deleted file mode 100644
index 734743a3a..000000000
--- a/app/javascript/crayons/MentionAutocomplete/__tests__/MentionAutocompleteCombobox.test.jsx
+++ /dev/null
@@ -1,110 +0,0 @@
-import { h } from 'preact';
-import { render, waitFor } from '@testing-library/preact';
-import userEvent from '@testing-library/user-event';
-import '@testing-library/jest-dom';
-import { axe } from 'jest-axe';
-import { MentionAutocompleteCombobox } from '../MentionAutocompleteCombobox';
-
-describe('', () => {
- const mockCoords = {
- x: 0,
- y: 0,
- };
-
- const mockOnSelect = jest.fn();
- const mockOnSearchtermChange = jest.fn();
-
- it('should have no a11y violations when rendered', async () => {
- const { container } = render(
- Promise.resolve([])}
- placementCoords={mockCoords}
- />,
- );
-
- const results = await axe(container);
-
- expect(results).toHaveNoViolations();
- });
-
- it('should render', () => {
- const { container } = render(
- Promise.resolve([])}
- placementCoords={mockCoords}
- />,
- );
-
- expect(container.innerHTML).toMatchSnapshot();
- });
-
- it('should not fetch suggestions with less than three characters', async () => {
- const mockFetchSuggestions = jest.fn();
-
- const { getByLabelText } = render(
- ,
- );
-
- expect(mockFetchSuggestions.mock.calls.length).toBe(0);
- const input = getByLabelText('mention user');
- userEvent.type(input, 'us');
- expect(mockFetchSuggestions.mock.calls.length).toBe(0);
- });
-
- it('should fetch and display suggestions when search text changes to more than 3 characters', async () => {
- const mockMatchingUser = {
- username: 'user_1',
- name: 'User One',
- profile_image_90: 'example.png',
- };
- const mockFetchSuggestions = jest.fn(() =>
- Promise.resolve([mockMatchingUser]),
- );
-
- const { getByLabelText, getByText } = render(
- ,
- );
-
- expect(mockFetchSuggestions.mock.calls.length).toBe(0);
-
- const input = getByLabelText('mention user');
- userEvent.type(input, 'use');
- expect(mockFetchSuggestions.mock.calls.length).toBe(1);
-
- await waitFor(() => expect(getByText('User One')).toBeInTheDocument());
- expect(getByText('@user_1')).toBeInTheDocument();
- });
-
- it('should display empty matches state', async () => {
- const { getByText, getByLabelText } = render(
- Promise.resolve([])}
- onSelect={mockOnSelect}
- onSearchTermChange={mockOnSearchtermChange}
- placementCoords={mockCoords}
- />,
- );
-
- const input = getByLabelText('mention user');
- userEvent.type(input, 'us');
-
- await waitFor(() =>
- expect(getByText('No results found')).toBeInTheDocument(),
- );
- });
-});
diff --git a/app/javascript/crayons/MentionAutocomplete/__tests__/__snapshots__/MentionAutocompleteCombobox.test.jsx.snap b/app/javascript/crayons/MentionAutocomplete/__tests__/__snapshots__/MentionAutocompleteCombobox.test.jsx.snap
deleted file mode 100644
index e4060b4df..000000000
--- a/app/javascript/crayons/MentionAutocomplete/__tests__/__snapshots__/MentionAutocompleteCombobox.test.jsx.snap
+++ /dev/null
@@ -1,3 +0,0 @@
-// Jest Snapshot v1, https://goo.gl/fbAQLP
-
-exports[` should render 1`] = `""`;
diff --git a/app/javascript/crayons/MentionAutocomplete/index.js b/app/javascript/crayons/MentionAutocomplete/index.js
deleted file mode 100644
index 12324f1f4..000000000
--- a/app/javascript/crayons/MentionAutocomplete/index.js
+++ /dev/null
@@ -1 +0,0 @@
-export * from './MentionAutocomplete';
diff --git a/app/javascript/crayons/MentionAutocompleteTextArea/MentionAutocompleteTextArea.jsx b/app/javascript/crayons/MentionAutocompleteTextArea/MentionAutocompleteTextArea.jsx
new file mode 100644
index 000000000..d9458ec62
--- /dev/null
+++ b/app/javascript/crayons/MentionAutocompleteTextArea/MentionAutocompleteTextArea.jsx
@@ -0,0 +1,265 @@
+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;
+
+const UserListItemContent = ({ user }) => {
+ return (
+
+
+
+
+
+
+
{user.name}
+
{`@${user.username}`}
+
+
+ );
+};
+
+/**
+ * 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
+ *
+ */
+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);
+
+ useEffect(() => {
+ if (searchTerm.length >= MIN_SEARCH_CHARACTERS) {
+ if (cachedSearches[searchTerm]) {
+ setUsers(cachedSearches[searchTerm]);
+ return;
+ }
+
+ fetchSuggestions(searchTerm).then(({ result: fetchedUsers }) => {
+ 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
+ const requiresAriaLiveAnnouncement =
+ !ariaHelperText && fetchedUsers.length > 0;
+
+ if (requiresAriaLiveAnnouncement) {
+ setAriaHelperText(
+ `Mention user, ${fetchedUsers.length} results found`,
+ );
+ }
+ });
+ }
+ }, [searchTerm, fetchSuggestions, cachedSearches, ariaHelperText]);
+
+ useLayoutEffect(() => {
+ const popover = document.getElementById('mention-autocomplete-popover');
+ 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(() => {
+ inputRef.current.focus();
+ inputRef.current.setSelectionRange(cursorPosition, cursorPosition - 1);
+ }, [cursorPosition]);
+
+ const handleValueChange = ({ 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) => {
+ 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) {
+ const attributes = replaceElement.attributes;
+ Object.keys(attributes).forEach((attributeKey) => {
+ inputRef.current.setAttribute(
+ attributes[attributeKey].name,
+ attributes[attributeKey].value,
+ );
+ });
+
+ inputRef.current.style.cssText = document.defaultView.getComputedStyle(
+ replaceElement,
+ '',
+ ).cssText;
+
+ // We need to manually remove the element, as Preact's diffing algorithm won't replace it in render
+ replaceElement.remove();
+ inputRef.current.focus();
+ }
+ }, [replaceElement]);
+
+ return (
+
+
+ {ariaHelperText}
+
+
+
+ {searchTerm && (
+
+ {users.length > 0 ? (
+
+ {users.map((user) => (
+
+
+
+ ))}
+
+ ) : (
+
+ {searchTerm.length >= MIN_SEARCH_CHARACTERS
+ ? 'No results found'
+ : 'Type to search for a user'}
+
+ )}
+
+ )}
+
+
+ );
+};
+
+MentionAutocompleteTextArea.propTypes = {
+ replaceElement: PropTypes.node.isRequired,
+ fetchSuggestions: PropTypes.func.isRequired,
+};
diff --git a/app/javascript/crayons/MentionAutocompleteTextArea/__stories__/MentionAutocompleteTextArea.stories.jsx b/app/javascript/crayons/MentionAutocompleteTextArea/__stories__/MentionAutocompleteTextArea.stories.jsx
new file mode 100644
index 000000000..e7a4409a9
--- /dev/null
+++ b/app/javascript/crayons/MentionAutocompleteTextArea/__stories__/MentionAutocompleteTextArea.stories.jsx
@@ -0,0 +1,99 @@
+import { h, createRef, render } from 'preact';
+import { MentionAutocompleteTextArea } from '../MentionAutocompleteTextArea';
+import notes from './mention-autocomplete.md';
+
+export default {
+ title: 'Components/MentionAutocompleteTextArea',
+ parameters: { notes },
+};
+
+async function fetchUsers(searchTerm) {
+ const exampleApiResult = [
+ {
+ username: 'user_one',
+ name: 'User One First Name Last Name',
+ profile_image_90: '/images/apple-icon.png',
+ },
+ {
+ username: 'user_two',
+ name: 'User Two',
+ profile_image_90: '/images/apple-icon.png',
+ },
+ {
+ username: 'user_three',
+ name: 'User Three',
+ profile_image_90: '/images/apple-icon.png',
+ },
+ {
+ username: 'user_four',
+ name: 'User Four',
+ profile_image_90: '/images/apple-icon.png',
+ },
+ {
+ username: 'user_five',
+ name: 'User Five',
+ profile_image_90: '/images/apple-icon.png',
+ },
+ {
+ username: 'user_six',
+ name: 'User Six First Name Last Name Longer',
+ profile_image_90: '/images/apple-icon.png',
+ },
+ ];
+
+ return {
+ result: exampleApiResult.filter((user) =>
+ user.username.includes(searchTerm),
+ ),
+ };
+}
+
+export const Default = () => {
+ const textAreaRef = createRef(null);
+
+ const handleAreaFocused = () => {
+ const container = document.getElementById('story-container');
+ if (
+ textAreaRef.current &&
+ !container.getAttribute('autocomplete-initialized')
+ ) {
+ const storybookElement = textAreaRef.current;
+
+ render(
+ ,
+ container,
+ storybookElement,
+ );
+
+ container.setAttribute('autocomplete-initialized', 'true');
+ }
+ };
+
+ return (
+
+
+
+ );
+};
+
+Default.story = {
+ name: 'default',
+};
diff --git a/app/javascript/crayons/MentionAutocompleteTextArea/__stories__/mention-autocomplete.md b/app/javascript/crayons/MentionAutocompleteTextArea/__stories__/mention-autocomplete.md
new file mode 100644
index 000000000..d28a9ec38
--- /dev/null
+++ b/app/javascript/crayons/MentionAutocompleteTextArea/__stories__/mention-autocomplete.md
@@ -0,0 +1,18 @@
+## Mention autocomplete
+
+The `MentionAutocompleteTextArea` component uses the [Reach UI Combobox](https://reach.tech/combobox/) under the hood. It works by _replacing_ the textarea you pass in props with one enhanced with the `@mention` functionality.
+
+The autocomplete will begin fetching suggestions once a user has typed `@` plus at least two characters. A user's selection is confirmed when they either:
+
+- Click on a search option
+- Hit enter on a search option
+- Click away from the dropdown
+- Enter space to move away from the autocomplete
+
+### Mention autocomplete accessibility
+
+The component replaces the given textarea with one generated using [Reach UI Combobox](https://reach.tech/combobox/). The underlying behavior then conforms to the [WAI_ARIA guidelines on comboboxes](https://www.w3.org/TR/wai-aria-practices-1.2/#combobox).
+
+An `aria-live` region communicates to a screen reader user when the list has been populated with suggestions.
+
+Please note: When using the `MentionAutocompleteTextArea`, you must have either an `aria-labelledby` or `aria-label` attribute on the textarea you pass as a prop. These attributes are copied across to the Reach UI Combobox input, ensuring it is labelled correctly for accessibility.
diff --git a/app/javascript/crayons/MentionAutocompleteTextArea/__tests__/MentionAutocompleteTextArea.test.jsx b/app/javascript/crayons/MentionAutocompleteTextArea/__tests__/MentionAutocompleteTextArea.test.jsx
new file mode 100644
index 000000000..9c0d56a3f
--- /dev/null
+++ b/app/javascript/crayons/MentionAutocompleteTextArea/__tests__/MentionAutocompleteTextArea.test.jsx
@@ -0,0 +1,52 @@
+import { h } from 'preact';
+import { render } from '@testing-library/preact';
+import '@testing-library/jest-dom';
+import { MentionAutocompleteTextArea } from '../MentionAutocompleteTextArea';
+
+describe('', () => {
+ const textArea = document.createElement('textarea');
+ textArea.setAttribute('aria-label', 'test text area');
+ textArea.setAttribute('id', 'test-text-area');
+
+ beforeAll(() => {
+ global.window.matchMedia = jest.fn((query) => {
+ return {
+ matches: false,
+ media: query,
+ addListener: jest.fn(),
+ removeListener: jest.fn(),
+ };
+ });
+ });
+
+ beforeEach(() => {
+ document.body.appendChild(textArea);
+ });
+
+ it('should render', () => {
+ const { container } = render(
+ ({ result: [] })}
+ />,
+ );
+
+ expect(container.innerHTML).toMatchSnapshot();
+ });
+
+ it('should replace the given textarea with a combobox textarea', () => {
+ const { getByRole, getAllByLabelText } = render(
+ ({ result: [] })}
+ />,
+ );
+
+ const combobox = getByRole('combobox');
+ expect(combobox).toBeInTheDocument();
+ expect(combobox.id).toEqual('test-text-area');
+ expect(combobox.getAttribute('aria-label')).toEqual('test text area');
+
+ expect(getAllByLabelText('test text area')).toHaveLength(1);
+ });
+});
diff --git a/app/javascript/crayons/MentionAutocompleteTextArea/__tests__/__snapshots__/MentionAutocompleteTextArea.test.jsx.snap b/app/javascript/crayons/MentionAutocompleteTextArea/__tests__/__snapshots__/MentionAutocompleteTextArea.test.jsx.snap
new file mode 100644
index 000000000..9859b017f
--- /dev/null
+++ b/app/javascript/crayons/MentionAutocompleteTextArea/__tests__/__snapshots__/MentionAutocompleteTextArea.test.jsx.snap
@@ -0,0 +1,3 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[` should render 1`] = `""`;
diff --git a/app/javascript/crayons/MentionAutocompleteTextArea/index.js b/app/javascript/crayons/MentionAutocompleteTextArea/index.js
new file mode 100644
index 000000000..566452145
--- /dev/null
+++ b/app/javascript/crayons/MentionAutocompleteTextArea/index.js
@@ -0,0 +1 @@
+export * from './MentionAutocompleteTextArea';
diff --git a/app/javascript/utilities/__tests__/textAreaUtils.test.js b/app/javascript/utilities/__tests__/textAreaUtils.test.js
new file mode 100644
index 000000000..e8e17979f
--- /dev/null
+++ b/app/javascript/utilities/__tests__/textAreaUtils.test.js
@@ -0,0 +1,68 @@
+import { getMentionWordData } from '../textAreaUtils';
+
+describe('getMentionWordData', () => {
+ it('returns userMention false for cursor at start of input', () => {
+ const inputState = {
+ selectionStart: 0,
+ value: 'text with @mention',
+ };
+
+ const { isUserMention, indexOfMentionStart } = getMentionWordData(
+ inputState,
+ );
+ expect(isUserMention).toBe(false);
+ expect(indexOfMentionStart).toEqual(-1);
+ });
+
+ it('returns userMention false for empty input value', () => {
+ const inputState = {
+ selectionStart: 10,
+ value: '',
+ };
+
+ const { isUserMention, indexOfMentionStart } = getMentionWordData(
+ inputState,
+ );
+ expect(isUserMention).toBe(false);
+ expect(indexOfMentionStart).toEqual(-1);
+ });
+
+ it('returns userMention false if no @ symbol exists at start of word', () => {
+ const inputState = {
+ selectionStart: 13,
+ value: 'text with no mention',
+ };
+
+ const { isUserMention, indexOfMentionStart } = getMentionWordData(
+ inputState,
+ );
+ expect(isUserMention).toBe(false);
+ expect(indexOfMentionStart).toEqual(-1);
+ });
+
+ it('returns userMention true and correct index for an @ mention at beginning of input', () => {
+ const inputState = {
+ selectionStart: 3,
+ value: '@mention',
+ };
+
+ const { isUserMention, indexOfMentionStart } = getMentionWordData(
+ inputState,
+ );
+ expect(isUserMention).toBe(true);
+ expect(indexOfMentionStart).toEqual(0);
+ });
+
+ it('returns userMention true and correct index for @ mention in middle of input', () => {
+ const inputState = {
+ selectionStart: 13,
+ value: 'text with @mention',
+ };
+
+ const { isUserMention, indexOfMentionStart } = getMentionWordData(
+ inputState,
+ );
+ expect(isUserMention).toBe(true);
+ expect(indexOfMentionStart).toEqual(10);
+ });
+});
diff --git a/app/javascript/utilities/textAreaUtils.js b/app/javascript/utilities/textAreaUtils.js
index 5efbb07d2..4768c44ed 100644
--- a/app/javascript/utilities/textAreaUtils.js
+++ b/app/javascript/utilities/textAreaUtils.js
@@ -11,15 +11,25 @@
* const coordinates = getCursorXY(elementRef.current, elementRef.current.selectionStart)
*/
export const getCursorXY = (input, selectionPoint) => {
- const { offsetLeft: inputX, offsetTop: inputY } = input;
+ const bodyRect = document.body.getBoundingClientRect();
+ const elementRect = input.getBoundingClientRect();
+
+ const inputY = elementRect.top - bodyRect.top;
+ const inputX = elementRect.left - bodyRect.left;
// create a dummy element with the computed style of the input
- const div = top.document.createElement('div');
+ const div = document.createElement('div');
const copyStyle = getComputedStyle(input);
for (const prop of copyStyle) {
div.style[prop] = copyStyle[prop];
}
+ // set the div to the correct position
+ div.style['position'] = 'absolute';
+ div.style['top'] = `${inputY}px`;
+ div.style['left'] = `${inputX}px`;
+ div.style['opacity'] = 0;
+
// replace whitespace with '.' when filling the dummy element if it's a single line
const swap = '.';
const inputValue =
@@ -33,22 +43,75 @@ export const getCursorXY = (input, selectionPoint) => {
if (input.tagName === 'INPUT') div.style.width = 'auto';
// marker element to obtain caret position
- const span = top.document.createElement('span');
+ const span = document.createElement('span');
// give the span the textContent of remaining content so that the recreated dummy element is as close as possible
span.textContent = inputValue.substr(selectionPoint) || '.';
// append the span marker to the div and the dummy element to the body
div.appendChild(span);
- top.document.body.appendChild(div);
+ document.body.appendChild(div);
// get the marker position, this is the caret position top and left relative to the input
const { offsetLeft: spanX, offsetTop: spanY } = span;
// remove dummy element
- top.document.body.removeChild(div);
+ document.body.removeChild(div);
+
// return object with the x and y of the caret. account for input positioning so that you don't need to wrap the input
return {
x: inputX + spanX,
y: inputY + spanY,
};
};
+
+/**
+ * A helper function that searches back to the beginning of the currently typed word (indicated by cursor position) and verifies whether it begins with an '@' symbol for user mention
+ *
+ * @param {element} textArea The text area or input to inspect the current word of
+ * @returns {{isUserMention: boolean, indexOfMentionStart: number}} Object with the word's mention data
+ *
+ * @example
+ * const { isUserMention, indexOfMentionStart } = getMentionWordData(textArea);
+ * if (isUserMention) {
+ * // Do something
+ * }
+ */
+export const getMentionWordData = (textArea) => {
+ const { selectionStart, value: valueBeforeKeystroke } = textArea;
+
+ if (selectionStart === 0 || valueBeforeKeystroke === '') {
+ return {
+ isUserMention: false,
+ indexOfMentionStart: -1,
+ };
+ }
+
+ const indexOfAutocompleteStart = getIndexOfCurrentWordAutocompleteSymbol(
+ valueBeforeKeystroke,
+ selectionStart,
+ );
+
+ return {
+ isUserMention: indexOfAutocompleteStart !== -1,
+ indexOfMentionStart: indexOfAutocompleteStart,
+ };
+};
+
+const getIndexOfCurrentWordAutocompleteSymbol = (content, selectionIndex) => {
+ const currentCharacter = content.charAt(selectionIndex);
+ const previousCharacter = content.charAt(selectionIndex - 1);
+
+ if (
+ selectionIndex !== 0 &&
+ previousCharacter !== ' ' &&
+ previousCharacter !== ''
+ ) {
+ return getIndexOfCurrentWordAutocompleteSymbol(content, selectionIndex - 1);
+ }
+
+ if (currentCharacter === '@') {
+ return selectionIndex;
+ }
+
+ return -1;
+};
diff --git a/config/webpack/environment.js b/config/webpack/environment.js
index 842312300..ed747673c 100644
--- a/config/webpack/environment.js
+++ b/config/webpack/environment.js
@@ -26,6 +26,8 @@ environment.splitChunks((config) => {
__dirname,
'../../app/javascript/shared/components',
),
+ react: 'preact/compat',
+ 'react-dom': 'preact/compat',
},
},
};