parent
1e4d4db562
commit
f393dd2bbc
19 changed files with 565 additions and 587 deletions
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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
|
||||
* <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)
|
||||
);
|
||||
};
|
||||
|
|
@ -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 (
|
||||
<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,
|
||||
};
|
||||
|
|
@ -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 (
|
||||
<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',
|
||||
};
|
||||
|
|
@ -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).
|
||||
|
|
@ -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('<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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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('<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(),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
// 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>"`;
|
||||
1
app/javascript/crayons/MentionAutocomplete/index.js
Normal file
1
app/javascript/crayons/MentionAutocomplete/index.js
Normal file
|
|
@ -0,0 +1 @@
|
|||
export * from './MentionAutocomplete';
|
||||
|
|
@ -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 (
|
||||
<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);
|
||||
|
||||
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 (
|
||||
<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={handleValueChange}
|
||||
/>
|
||||
{searchTerm && (
|
||||
<ComboboxPopover
|
||||
className="crayons-autocomplete__popover"
|
||||
id="mention-autocomplete-popover"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
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,
|
||||
};
|
||||
|
|
@ -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(
|
||||
<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',
|
||||
};
|
||||
|
|
@ -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.
|
||||
|
|
@ -1,52 +0,0 @@
|
|||
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);
|
||||
});
|
||||
});
|
||||
|
|
@ -1,3 +0,0 @@
|
|||
// 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>"`;
|
||||
|
|
@ -1 +0,0 @@
|
|||
export * from './MentionAutocompleteTextArea';
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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 <input/>
|
||||
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;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -26,8 +26,6 @@ environment.splitChunks((config) => {
|
|||
__dirname,
|
||||
'../../app/javascript/shared/components',
|
||||
),
|
||||
react: 'preact/compat',
|
||||
'react-dom': 'preact/compat',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue