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
This commit is contained in:
Suzanne Aitchison 2021-03-18 15:04:59 +00:00 committed by GitHub
parent 459378d218
commit e3c97d18d6
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
19 changed files with 596 additions and 565 deletions

View file

@ -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;

View file

@ -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(

View file

@ -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
* <Fragment>
* <textarea
* ref={textAreaRef}
* aria-label="test text area"/>
* <MentionAutocomplete
* textAreaRef={textAreaRef}
* fetchSuggestions={fetchUsers}
* />
* </Fragment>
*/
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(
<MentionAutocompleteCombobox
onSelect={handleSelection}
fetchSuggestions={fetchSuggestions}
placementCoords={cursorPlacementData}
onSearchTermChange={handleSearchTermChange}
/>,
container,
);
},
);
} else {
render(null, container);
}
}, [
cursorPlacementData,
fetchSuggestions,
handleSearchTermChange,
handleSelection,
isAutocompleteActive,
]);
return <span id="mention-autocomplete-container" />;
};
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)
);
};

View file

@ -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 (
<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 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
* <MentionAutocompleteCombobox
* startText="name"
* onSelect={handleUserMentionSelection}
* fetchSuggestions={fetchUsersByUsername}
* placementCoords={{x: 22, y: 0}}
* onSearchTermChange={updateSearchTermText}
* />
*/
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 (
<Combobox
aria-label="mention user"
onSelect={(item) => onSelect(`@${item}`)}
className="crayons-autocomplete"
>
<ComboboxInput
style={{
opacity: 0.000001,
}}
ref={inputRef}
onChange={handleSearchTermChange}
value={searchTerm}
selectOnClick
autocomplete={false}
/>
<ComboboxPopover
className="crayons-autocomplete__popover"
id="mention-autocomplete-popover"
style={{
position: 'absolute',
top: `calc(${placementCoords.y}px + 1.5rem)`,
left: `${placementCoords.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 >= 2
? 'No results found'
: 'Type to search for a user'}
</span>
)}
</ComboboxPopover>
</Combobox>
);
};
MentionAutocompleteCombobox.propTypes = {
onSelect: PropTypes.func.isRequired,
fetchSuggestions: PropTypes.func.isRequired,
placementCoords: PropTypes.shape({
x: PropTypes.number,
y: PropTypes.number,
}).isRequired,
onSearchTermChange: PropTypes.func.isRequired,
};

View file

@ -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 (
<Fragment>
<label style={{ display: 'flex', flexDirection: 'column' }}>
Enter text and type '@us' to start triggering search results
<textarea
ref={textAreaRef}
style={{ width: '500px', maxWidth: '100%', minHeight: '200px' }}
/>
</label>
<MentionAutocomplete
textAreaRef={textAreaRef}
fetchSuggestions={fetchUsers}
/>
</Fragment>
);
};
Default.story = {
name: 'default',
};

View file

@ -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).

View file

@ -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('<MentionAutocomplete />', () => {
const testTextAreaRef = createRef(null);
const testTextArea = (
<textarea ref={testTextAreaRef} aria-label="test text area" />
);
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(
<Fragment>
{testTextArea}
<MentionAutocomplete
textAreaRef={testTextAreaRef}
fetchSuggestions={mockFetchSuggestions}
/>
</Fragment>,
);
const results = await axe(container);
expect(results).toHaveNoViolations();
});
});

View file

@ -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('<MentionAutocomplete />', () => {
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(
<MentionAutocompleteCombobox
onSelect={mockOnSelect}
onSearchTermChange={mockOnSearchtermChange}
fetchSuggestions={() => Promise.resolve([])}
placementCoords={mockCoords}
/>,
);
const results = await axe(container);
expect(results).toHaveNoViolations();
});
it('should render', () => {
const { container } = render(
<MentionAutocompleteCombobox
onSelect={mockOnSelect}
onSearchTermChange={mockOnSearchtermChange}
fetchSuggestions={() => 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(
<MentionAutocompleteCombobox
startText="us"
onSelect={mockOnSelect}
onSearchTermChange={mockOnSearchtermChange}
fetchSuggestions={mockFetchSuggestions}
placementCoords={mockCoords}
/>,
);
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(
<MentionAutocompleteCombobox
onSelect={mockOnSelect}
onSearchTermChange={mockOnSearchtermChange}
fetchSuggestions={mockFetchSuggestions}
placementCoords={mockCoords}
/>,
);
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(
<MentionAutocompleteCombobox
fetchSuggestions={() => 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(),
);
});
});

View file

@ -1,3 +0,0 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`<MentionAutocomplete /> should render 1`] = `"<div class=\\"crayons-autocomplete\\" data-reach-combobox=\\"\\" data-state=\\"idle\\"><input aria-autocomplete=\\"both\\" aria-controls=\\"listbox--5\\" aria-expanded=\\"false\\" aria-haspopup=\\"listbox\\" aria-label=\\"mention user\\" role=\\"combobox\\" style=\\"opacity: 0.000001;\\" data-reach-combobox-input=\\"\\" data-state=\\"idle\\"></div>"`;

View file

@ -1 +0,0 @@
export * from './MentionAutocomplete';

View file

@ -0,0 +1,274 @@
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,
};

View file

@ -0,0 +1,99 @@
import { h, createRef, render } from 'preact';
import { MentionAutocompleteTextArea } from '../MentionAutocompleteTextArea';
import notes from './mention-autocomplete.md';
export default {
title: 'App 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(
<MentionAutocompleteTextArea
replaceElement={storybookElement}
fetchSuggestions={fetchUsers}
labelId="storybook-autocomplete-label"
/>,
container,
storybookElement,
);
container.setAttribute('autocomplete-initialized', 'true');
}
};
return (
<div id="story-container">
<label id="storybook-autocomplete-label">
Enter text and type '@us' to start triggering search results
<div>
<textarea
ref={textAreaRef}
aria-labelledby="storybook-autocomplete-label"
onFocus={handleAreaFocused}
style={{
width: '500px',
maxWidth: '100%',
minHeight: '200px',
}}
/>
</div>
</label>
</div>
);
};
Default.story = {
name: 'default',
};

View file

@ -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&apos;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.

View file

@ -0,0 +1,52 @@
import { h } from 'preact';
import { render } from '@testing-library/preact';
import '@testing-library/jest-dom';
import { MentionAutocompleteTextArea } from '../MentionAutocompleteTextArea';
describe('<MentionAutocompleteTextArea />', () => {
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(
<MentionAutocompleteTextArea
replaceElement={textArea}
fetchSuggestions={async () => ({ result: [] })}
/>,
);
expect(container.innerHTML).toMatchSnapshot();
});
it('should replace the given textarea with a combobox textarea', () => {
const { getByRole, getAllByLabelText } = render(
<MentionAutocompleteTextArea
replaceElement={textArea}
fetchSuggestions={async () => ({ 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);
});
});

View file

@ -0,0 +1,3 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`<MentionAutocompleteTextArea /> should render 1`] = `"<div aria-live=\\"polite\\" class=\\"screen-reader-only\\"></div><div id=\\"combobox-container\\" class=\\"crayons-autocomplete\\" data-reach-combobox=\\"\\" data-state=\\"idle\\"><textarea aria-autocomplete=\\"both\\" aria-controls=\\"listbox--combobox-container\\" aria-expanded=\\"false\\" aria-haspopup=\\"listbox\\" role=\\"combobox\\" data-mention-autocomplete-active=\\"true\\" data-reach-combobox-input=\\"\\" data-state=\\"idle\\" aria-label=\\"test text area\\" id=\\"test-text-area\\" style=\\"font: -webkit-small-control; text-rendering: auto; letter-spacing: normal; word-spacing: normal; line-height: normal; text-transform: none; text-indent: 0; text-shadow: none; display: inline-block; text-align: start; background-color: white; border: 1px solid; flex-direction: column; resize: auto; cursor: auto; padding: 2px; white-space: pre-wrap; word-wrap: break-word; visibility: visible;\\"></textarea></div>"`;

View file

@ -0,0 +1 @@
export * from './MentionAutocompleteTextArea';

View file

@ -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);
});
});

View file

@ -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 <input/>
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;
};

View file

@ -26,6 +26,8 @@ environment.splitChunks((config) => {
__dirname,
'../../app/javascript/shared/components',
),
react: 'preact/compat',
'react-dom': 'preact/compat',
},
},
};