diff --git a/app/assets/stylesheets/base/helpers.scss b/app/assets/stylesheets/base/helpers.scss
index f3a664adb..abc7db77e 100644
--- a/app/assets/stylesheets/base/helpers.scss
+++ b/app/assets/stylesheets/base/helpers.scss
@@ -36,15 +36,6 @@
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 933a4afe3..e3d3323c1 100644
--- a/app/assets/stylesheets/components/autocomplete.scss
+++ b/app/assets/stylesheets/components/autocomplete.scss
@@ -2,9 +2,8 @@
.crayons-autocomplete {
&__popover {
- max-width: 300px;
+ min-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
new file mode 100644
index 000000000..3c541c3e8
--- /dev/null
+++ b/app/javascript/crayons/MentionAutocomplete/MentionAutocomplete.jsx
@@ -0,0 +1,141 @@
+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
new file mode 100644
index 000000000..1319abc7c
--- /dev/null
+++ b/app/javascript/crayons/MentionAutocomplete/MentionAutocompleteCombobox.jsx
@@ -0,0 +1,177 @@
+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
new file mode 100644
index 000000000..e2034fb07
--- /dev/null
+++ b/app/javascript/crayons/MentionAutocomplete/__stories__/MentionAutocomplete.stories.jsx
@@ -0,0 +1,75 @@
+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
new file mode 100644
index 000000000..76e7c8c57
--- /dev/null
+++ b/app/javascript/crayons/MentionAutocomplete/__stories__/mention-autocomplete.md
@@ -0,0 +1,14 @@
+## 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
new file mode 100644
index 000000000..9052d3569
--- /dev/null
+++ b/app/javascript/crayons/MentionAutocomplete/__tests__/MentionAutocomplete.test.jsx
@@ -0,0 +1,38 @@
+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
new file mode 100644
index 000000000..734743a3a
--- /dev/null
+++ b/app/javascript/crayons/MentionAutocomplete/__tests__/MentionAutocompleteCombobox.test.jsx
@@ -0,0 +1,110 @@
+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
new file mode 100644
index 000000000..e4060b4df
--- /dev/null
+++ b/app/javascript/crayons/MentionAutocomplete/__tests__/__snapshots__/MentionAutocompleteCombobox.test.jsx.snap
@@ -0,0 +1,3 @@
+// 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
new file mode 100644
index 000000000..12324f1f4
--- /dev/null
+++ b/app/javascript/crayons/MentionAutocomplete/index.js
@@ -0,0 +1 @@
+export * from './MentionAutocomplete';
diff --git a/app/javascript/crayons/MentionAutocompleteTextArea/MentionAutocompleteTextArea.jsx b/app/javascript/crayons/MentionAutocompleteTextArea/MentionAutocompleteTextArea.jsx
deleted file mode 100644
index d9458ec62..000000000
--- a/app/javascript/crayons/MentionAutocompleteTextArea/MentionAutocompleteTextArea.jsx
+++ /dev/null
@@ -1,265 +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';
-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
deleted file mode 100644
index e7a4409a9..000000000
--- a/app/javascript/crayons/MentionAutocompleteTextArea/__stories__/MentionAutocompleteTextArea.stories.jsx
+++ /dev/null
@@ -1,99 +0,0 @@
-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
deleted file mode 100644
index d28a9ec38..000000000
--- a/app/javascript/crayons/MentionAutocompleteTextArea/__stories__/mention-autocomplete.md
+++ /dev/null
@@ -1,18 +0,0 @@
-## 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
deleted file mode 100644
index 9c0d56a3f..000000000
--- a/app/javascript/crayons/MentionAutocompleteTextArea/__tests__/MentionAutocompleteTextArea.test.jsx
+++ /dev/null
@@ -1,52 +0,0 @@
-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
deleted file mode 100644
index 9859b017f..000000000
--- a/app/javascript/crayons/MentionAutocompleteTextArea/__tests__/__snapshots__/MentionAutocompleteTextArea.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/MentionAutocompleteTextArea/index.js b/app/javascript/crayons/MentionAutocompleteTextArea/index.js
deleted file mode 100644
index 566452145..000000000
--- a/app/javascript/crayons/MentionAutocompleteTextArea/index.js
+++ /dev/null
@@ -1 +0,0 @@
-export * from './MentionAutocompleteTextArea';
diff --git a/app/javascript/utilities/__tests__/textAreaUtils.test.js b/app/javascript/utilities/__tests__/textAreaUtils.test.js
deleted file mode 100644
index e8e17979f..000000000
--- a/app/javascript/utilities/__tests__/textAreaUtils.test.js
+++ /dev/null
@@ -1,68 +0,0 @@
-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 4768c44ed..5efbb07d2 100644
--- a/app/javascript/utilities/textAreaUtils.js
+++ b/app/javascript/utilities/textAreaUtils.js
@@ -11,25 +11,15 @@
* const coordinates = getCursorXY(elementRef.current, elementRef.current.selectionStart)
*/
export const getCursorXY = (input, selectionPoint) => {
- const bodyRect = document.body.getBoundingClientRect();
- const elementRect = input.getBoundingClientRect();
-
- const inputY = elementRect.top - bodyRect.top;
- const inputX = elementRect.left - bodyRect.left;
+ const { offsetLeft: inputX, offsetTop: inputY } = input;
// create a dummy element with the computed style of the input
- const div = document.createElement('div');
+ const div = top.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 =
@@ -43,75 +33,22 @@ export const getCursorXY = (input, selectionPoint) => {
if (input.tagName === 'INPUT') div.style.width = 'auto';
// marker element to obtain caret position
- const span = document.createElement('span');
+ const span = top.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);
- document.body.appendChild(div);
+ top.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
- document.body.removeChild(div);
-
+ top.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 ed747673c..842312300 100644
--- a/config/webpack/environment.js
+++ b/config/webpack/environment.js
@@ -26,8 +26,6 @@ environment.splitChunks((config) => {
__dirname,
'../../app/javascript/shared/components',
),
- react: 'preact/compat',
- 'react-dom': 'preact/compat',
},
},
};