Add new tag autocomplete to editor (#16025)
* add default placeholder, remove focus on first load * fix some bugs re autofocus and mouse click to select * allow custom selected styles to be passed in * operate on objects with name property rather than plain strings * WIP main functionality in place * set default selections, allow a max to be placed on selections * switch help context * bug fixes to edit mode, static suggestions * make sure suggestion resumes when edit begins * cleanup and docs * update existing form test * add component tests * add more component test cases * refactor max selections flow, ensure default tag data only loads once * stop removing combobox properties now the input stays visible * add max selections test * refactor * make sure input refocus happens after blur event * update cypress tests * some small renames and doc changes * only fetch exact matches from added tags * fix test, update dark theme background * set a max height on the popover, and ensure options can be scrolled into view * woops - max height * Update app/javascript/article-form/components/TagsField.jsx Co-authored-by: Ridhwana <Ridhwana.Khan16@gmail.com> * refactors * stop dropdown from flickering * use ButtonNew * remove redundant variant * nudge PR checks Co-authored-by: Ridhwana <Ridhwana.Khan16@gmail.com>
This commit is contained in:
parent
87ff8f16a1
commit
222ce06f0f
17 changed files with 1569 additions and 296 deletions
|
|
@ -60,7 +60,7 @@
|
|||
}
|
||||
|
||||
.c-autocomplete--multi {
|
||||
&__wrapper:focus-within {
|
||||
&__wrapper-border:focus-within {
|
||||
@extend %form-styling-focus;
|
||||
|
||||
.c-autocomplete--multi__input {
|
||||
|
|
@ -69,12 +69,18 @@
|
|||
}
|
||||
|
||||
&__input {
|
||||
background-color: var(--form-bg);
|
||||
background-color: var(--bg-color);
|
||||
color: var(--body-color);
|
||||
border: none;
|
||||
}
|
||||
|
||||
.crayons-btn.c-autocomplete--multi__selected {
|
||||
&__wrapper-border {
|
||||
.c-autocomplete--multi__input {
|
||||
background-color: var(--form-bg);
|
||||
}
|
||||
}
|
||||
|
||||
.c-btn.c-autocomplete--multi__selected {
|
||||
&:first-child {
|
||||
border-top-right-radius: 0;
|
||||
border-bottom-right-radius: 0;
|
||||
|
|
@ -91,7 +97,11 @@
|
|||
}
|
||||
}
|
||||
|
||||
ul.c-autocomplete--multi__popover {
|
||||
.c-autocomplete--multi__popover {
|
||||
position: absolute;
|
||||
max-height: 500px;
|
||||
overflow: auto;
|
||||
width: 100%;
|
||||
padding: var(--su-1);
|
||||
z-index: var(--z-elevate);
|
||||
background: var(--card-bg);
|
||||
|
|
|
|||
|
|
@ -238,6 +238,32 @@
|
|||
}
|
||||
|
||||
.crayons-article-form {
|
||||
&__top-tags-heading {
|
||||
padding: var(--su-3);
|
||||
font-size: var(--fs-l);
|
||||
border-bottom: 1px solid var(--base-20);
|
||||
}
|
||||
|
||||
&__tag-selection {
|
||||
--tag-bg-hover: var(--base-a5);
|
||||
--tag-bg: var(--base-a5);
|
||||
|
||||
.c-btn {
|
||||
color: var(--base-80);
|
||||
background-color: var(--tag-bg);
|
||||
}
|
||||
|
||||
.c-btn:hover:enabled,
|
||||
.c-btn:focus-visible:enabled {
|
||||
color: var(--base-80);
|
||||
background-color: var(--tag-bg-hover);
|
||||
}
|
||||
}
|
||||
|
||||
&__tag-prefix {
|
||||
color: var(--tag-prefix);
|
||||
}
|
||||
|
||||
&__tag-option {
|
||||
background: var(--card-bg);
|
||||
color: var(--card-color);
|
||||
|
|
@ -263,80 +289,28 @@
|
|||
}
|
||||
}
|
||||
|
||||
[aria-selected='true'] {
|
||||
.crayons-article-form__tag-option {
|
||||
--tag-prefix: var(--accent-brand-darker) !important;
|
||||
background: var(--body-bg);
|
||||
|
||||
.crayons-article-form__tag-option-title {
|
||||
color: var(--accent-brand-darker);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&__tag-option-image {
|
||||
height: 1rem;
|
||||
margin-left: var(--su-1);
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: Clean up these old styles when Autocomplete component is added
|
||||
&__tagsoptions {
|
||||
background: var(--card-bg);
|
||||
color: var(--card-color);
|
||||
box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1),
|
||||
0 4px 6px -2px rgba(0, 0, 0, 0.05), 0 0 0 1px rgba(0, 0, 0, 0.1);
|
||||
border-radius: var(--radius);
|
||||
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
top: calc(100% + var(--su-4));
|
||||
z-index: var(--z-dropdown);
|
||||
padding: var(--su-1);
|
||||
|
||||
@media (min-width: $breakpoint-s) {
|
||||
left: calc(var(--su-2) * -1);
|
||||
right: calc(var(--su-2) * -1);
|
||||
}
|
||||
|
||||
@media (min-width: $breakpoint-m) {
|
||||
left: calc(var(--su-4) * -1);
|
||||
right: calc(var(--su-4) * -1);
|
||||
}
|
||||
}
|
||||
|
||||
&__tagsoptionsheading {
|
||||
padding: var(--su-3);
|
||||
font-size: var(--fs-l);
|
||||
border-bottom: 1px solid var(--base-20);
|
||||
}
|
||||
|
||||
&__tagname {
|
||||
color: var(--base-90);
|
||||
font-weight: var(--fw-medium);
|
||||
}
|
||||
|
||||
&__tagoptionrow {
|
||||
cursor: pointer;
|
||||
color: var(--link-color);
|
||||
padding: var(--su-3);
|
||||
|
||||
&:hover {
|
||||
background: var(--link-bg-hover);
|
||||
.crayons-tag {
|
||||
color: var(--link-color-hover);
|
||||
}
|
||||
}
|
||||
|
||||
&--active {
|
||||
background: var(--link-bg-hover);
|
||||
.crayons-article-form__tagname {
|
||||
color: var(--link-color-hover);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&__tagsoptionrulesbutton {
|
||||
display: none;
|
||||
}
|
||||
&__tagrules,
|
||||
&__tagrules--inactive {
|
||||
font-size: var(--fs-s);
|
||||
color: var(--base-70);
|
||||
}
|
||||
|
||||
&__tagsoptionsbottomrow {
|
||||
display: none;
|
||||
}
|
||||
.js-focus-visible
|
||||
.crayons-article-form__tag-selection
|
||||
.c-btn.focus-visible:focus {
|
||||
background-color: var(--tag-bg);
|
||||
color: var(--base-80);
|
||||
}
|
||||
|
||||
// Styling for drag'n'drop functionality...
|
||||
|
|
|
|||
|
|
@ -5,16 +5,18 @@ import PropTypes from 'prop-types';
|
|||
* Responsible for the layout of a tag "suggestion" in the article form
|
||||
*
|
||||
* @param {string} name The tag name
|
||||
* @param {string} backgroundColor Optional hex code for tag
|
||||
* @param {string} shortSummary Optional short summary of the tag
|
||||
* @param {string} badgeUrl Optional src for the tag's badge
|
||||
* @param {string} bg_color_hex Optional hex code for tag
|
||||
* @param {string} short_summary Optional short summary of the tag
|
||||
* @param {string} badge Optional object containing badge details
|
||||
*/
|
||||
export const TagAutocompleteOption = ({
|
||||
name,
|
||||
backgroundColor,
|
||||
shortSummary,
|
||||
badgeUrl,
|
||||
bg_color_hex: backgroundColor,
|
||||
short_summary: shortSummary,
|
||||
badge,
|
||||
}) => {
|
||||
const badgeUrl = badge?.['badge_image']?.url;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="crayons-article-form__tag-option"
|
||||
|
|
@ -40,7 +42,9 @@ export const TagAutocompleteOption = ({
|
|||
|
||||
TagAutocompleteOption.propTypes = {
|
||||
name: PropTypes.string.isRequired,
|
||||
backgroundColor: PropTypes.string,
|
||||
shortSummary: PropTypes.string,
|
||||
badgeUrl: PropTypes.string,
|
||||
background_color_hex: PropTypes.string,
|
||||
short_summary: PropTypes.string,
|
||||
badge: PropTypes.shape({
|
||||
badge_image: PropTypes.shape({ url: PropTypes.string }),
|
||||
}),
|
||||
};
|
||||
|
|
|
|||
|
|
@ -0,0 +1,59 @@
|
|||
import { h } from 'preact';
|
||||
import PropTypes from 'prop-types';
|
||||
import { ButtonNew as Button, Icon } from '@crayons';
|
||||
import { Close } from '@images/x.svg';
|
||||
|
||||
/**
|
||||
* Responsible for the layout of a tag autocomplete selected item in the article form
|
||||
*
|
||||
* @param {string} name The tag name
|
||||
* @param {string} bg_color_hex Optional background color hex code
|
||||
* @param {Function} onEdit Callback for tag edit click
|
||||
* @param {Function} onDeselect Callback for tag deselect click
|
||||
*/
|
||||
export const TagAutocompleteSelection = ({
|
||||
name,
|
||||
bg_color_hex: bgColorHex,
|
||||
onEdit,
|
||||
onDeselect,
|
||||
}) => {
|
||||
const baseColorStyles = bgColorHex
|
||||
? {
|
||||
'--tag-bg': `${bgColorHex}1a`,
|
||||
'--tag-bg-hover': `${bgColorHex}1a`,
|
||||
'--tag-prefix': bgColorHex,
|
||||
}
|
||||
: {};
|
||||
return (
|
||||
<div
|
||||
role="group"
|
||||
aria-label={name}
|
||||
className="crayons-article-form__tag-selection flex mr-1 mb-1 w-max"
|
||||
>
|
||||
<Button
|
||||
style={baseColorStyles}
|
||||
className="c-autocomplete--multi__selected p-1 cursor-text"
|
||||
aria-label={`Edit ${name}`}
|
||||
onClick={onEdit}
|
||||
>
|
||||
<span className="crayons-article-form__tag-prefix"># </span>
|
||||
{name}
|
||||
</Button>
|
||||
<Button
|
||||
style={baseColorStyles}
|
||||
className="c-autocomplete--multi__selected p-1"
|
||||
aria-label={`Remove ${name}`}
|
||||
onClick={onDeselect}
|
||||
>
|
||||
<Icon src={Close} />
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
TagAutocompleteSelection.propTypes = {
|
||||
name: PropTypes.string.isRequired,
|
||||
bg_color_hex: PropTypes.string,
|
||||
onEdit: PropTypes.func.isRequired,
|
||||
onDeselect: PropTypes.func.isRequired,
|
||||
};
|
||||
|
|
@ -1,34 +1,89 @@
|
|||
import { h } from 'preact';
|
||||
import { useEffect, useState } from 'preact/hooks';
|
||||
import PropTypes from 'prop-types';
|
||||
import { Tags } from '../../shared/components/tags';
|
||||
import { TagAutocompleteOption } from './TagAutocompleteOption';
|
||||
import { TagAutocompleteSelection } from './TagAutocompleteSelection';
|
||||
import { MultiSelectAutocomplete } from '@crayons';
|
||||
import { fetchSearch } from '@utilities/search';
|
||||
|
||||
export const DEFAULT_TAG_FORMAT = '[0-9A-Za-z, ]+';
|
||||
/**
|
||||
* TagsField for the article form. Allows users to search and select up to 4 tags.
|
||||
*
|
||||
* @param {Function} onInput Callback to sync selections to article form state
|
||||
* @param {string} defaultValue Comma separated list of any currently selected tags
|
||||
* @param {Function} switchHelpContext Callback to switch the help context when the field is focused
|
||||
*/
|
||||
export const TagsField = ({ onInput, defaultValue, switchHelpContext }) => {
|
||||
const [defaultSelections, setDefaultSelections] = useState([]);
|
||||
const [defaultsLoaded, setDefaultsLoaded] = useState(false);
|
||||
const [topTags, setTopTags] = useState([]);
|
||||
|
||||
useEffect(() => {
|
||||
fetch('/tags/suggest')
|
||||
.then((res) => res.json())
|
||||
.then((results) => setTopTags(results));
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
// Previously selected tags are passed as a plain comma separated string
|
||||
// Fetching further tag data allows us to display a richer UI
|
||||
// This fetch only happens once on first component load
|
||||
if (defaultValue && defaultValue !== '' && !defaultsLoaded) {
|
||||
const tagNames = defaultValue.split(', ');
|
||||
|
||||
const tagRequests = tagNames.map((tagName) =>
|
||||
fetchSearch('tags', { name: tagName }).then(({ result = [] }) => {
|
||||
const [potentialMatch = {}] = result;
|
||||
return potentialMatch.name === tagName
|
||||
? potentialMatch
|
||||
: { name: tagName };
|
||||
}),
|
||||
);
|
||||
|
||||
Promise.all(tagRequests).then((data) => {
|
||||
setDefaultSelections(data);
|
||||
});
|
||||
}
|
||||
setDefaultsLoaded(true);
|
||||
}, [defaultValue, defaultsLoaded]);
|
||||
|
||||
// Converts the array of selected items into a plain string to be saved in the article form
|
||||
const syncSelections = (selections = []) => {
|
||||
const selectionsString = selections
|
||||
.map((selection) => selection.name)
|
||||
.join(', ');
|
||||
onInput(selectionsString);
|
||||
};
|
||||
|
||||
const fetchSuggestions = (searchTerm) =>
|
||||
fetchSearch('tags', { name: searchTerm }).then(
|
||||
(response) => response.result,
|
||||
);
|
||||
|
||||
export const TagsField = ({
|
||||
defaultValue,
|
||||
onInput,
|
||||
switchHelpContext,
|
||||
tagFormat = DEFAULT_TAG_FORMAT,
|
||||
}) => {
|
||||
return (
|
||||
<div className="crayons-article-form__tagsfield">
|
||||
<Tags
|
||||
defaultValue={defaultValue}
|
||||
maxTags={4}
|
||||
onInput={onInput}
|
||||
onFocus={switchHelpContext}
|
||||
classPrefix="crayons-article-form"
|
||||
fieldClassName="crayons-textfield crayons-textfield--ghost ff-monospace"
|
||||
pattern={tagFormat}
|
||||
/>
|
||||
</div>
|
||||
<MultiSelectAutocomplete
|
||||
defaultValue={defaultSelections}
|
||||
fetchSuggestions={fetchSuggestions}
|
||||
staticSuggestions={topTags}
|
||||
staticSuggestionsHeading={
|
||||
<h2 className="crayons-article-form__top-tags-heading">Top tags</h2>
|
||||
}
|
||||
labelText="Add up to 4 tags"
|
||||
showLabel={false}
|
||||
placeholder="Add up to 4 tags..."
|
||||
border={false}
|
||||
maxSelections={4}
|
||||
SuggestionTemplate={TagAutocompleteOption}
|
||||
SelectionTemplate={TagAutocompleteSelection}
|
||||
onSelectionsChanged={syncSelections}
|
||||
onFocus={switchHelpContext}
|
||||
inputId="tag-input"
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
TagsField.propTypes = {
|
||||
onInput: PropTypes.func.isRequired,
|
||||
defaultValue: PropTypes.string.isRequired,
|
||||
switchHelpContext: PropTypes.func.isRequired,
|
||||
defaultValue: PropTypes.string,
|
||||
switchHelpContext: PropTypes.func,
|
||||
};
|
||||
|
||||
TagsField.displayName = 'TagsField';
|
||||
|
|
|
|||
|
|
@ -1,10 +1,13 @@
|
|||
import { h } from 'preact';
|
||||
import { render, waitFor } from '@testing-library/preact';
|
||||
import fetch from 'jest-fetch-mock';
|
||||
import '@testing-library/jest-dom';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { axe } from 'jest-axe';
|
||||
import { Form } from '../Form';
|
||||
|
||||
fetch.enableMocks();
|
||||
|
||||
let bodyMarkdown;
|
||||
let mainImage;
|
||||
|
||||
|
|
@ -95,10 +98,23 @@ describe('<Form />', () => {
|
|||
|
||||
describe('v2', () => {
|
||||
beforeEach(() => {
|
||||
fetch.resetMocks();
|
||||
|
||||
bodyMarkdown =
|
||||
'---↵title: Test Title v2↵published: false↵description: some description↵tags: javascript, career↵cover_image: https://dev-to-uploads.s3.amazonaws.com/uploads/badge/badge_image/12/8_week_streak-Shadow.png↵---↵↵Lets do this v2 changes↵↵';
|
||||
mainImage =
|
||||
'https://dev-to-uploads.s3.amazonaws.com/uploads/badge/badge_image/12/8_week_streak-Shadow.png';
|
||||
|
||||
window.fetch = fetch;
|
||||
window.getCsrfToken = async () => 'this-is-a-csrf-token';
|
||||
|
||||
fetch.mockResponse((req) =>
|
||||
Promise.resolve(
|
||||
req.url.includes('/tags/suggest')
|
||||
? '[]'
|
||||
: JSON.stringify({ result: [] }),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
it('should have no a11y violations', async () => {
|
||||
|
|
@ -145,7 +161,7 @@ describe('<Form />', () => {
|
|||
|
||||
getByAltText(/post cover/i);
|
||||
queryByTestId('article-form__title');
|
||||
getByLabelText('Post Tags');
|
||||
getByLabelText('Add up to 4 tags');
|
||||
queryByTestId('article-form__body');
|
||||
|
||||
const coverImageInput = getByLabelText('Change');
|
||||
|
|
|
|||
|
|
@ -0,0 +1,24 @@
|
|||
import { h } from 'preact';
|
||||
import { Icon, ButtonNew as Button } from '@crayons';
|
||||
import { Close } from '@images/x.svg';
|
||||
|
||||
export const DefaultSelectionTemplate = ({ name, onEdit, onDeselect }) => (
|
||||
<div role="group" aria-label={name} className="flex mr-1 mb-1 w-max">
|
||||
<Button
|
||||
variant="secondary"
|
||||
className="c-autocomplete--multi__selected p-1 cursor-text"
|
||||
aria-label={`Edit ${name}`}
|
||||
onClick={onEdit}
|
||||
>
|
||||
{name}
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
className="c-autocomplete--multi__selected p-1"
|
||||
aria-label={`Remove ${name}`}
|
||||
onClick={onDeselect}
|
||||
>
|
||||
<Icon src={Close} />
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
|
|
@ -1,9 +1,9 @@
|
|||
/* eslint-disable jsx-a11y/interactive-supports-focus, jsx-a11y/role-has-required-aria-props */
|
||||
// Disabled due to the linter being out of date for combobox role: https://github.com/jsx-eslint/eslint-plugin-jsx-a11y/issues/789
|
||||
import { h, Fragment } from 'preact';
|
||||
import PropTypes from 'prop-types';
|
||||
import { useEffect, useRef, useReducer } from 'preact/hooks';
|
||||
import { Icon, Button } from '@crayons';
|
||||
import { Close } from '@images/x.svg';
|
||||
import { DefaultSelectionTemplate } from './DefaultSelectionTemplate';
|
||||
|
||||
const KEYS = {
|
||||
UP: 'ArrowUp',
|
||||
|
|
@ -16,14 +16,15 @@ const KEYS = {
|
|||
};
|
||||
|
||||
const ALLOWED_CHARS_REGEX = /([a-zA-Z0-9])/;
|
||||
const PLACEHOLDER_SELECTIONS_MADE = 'Add another...';
|
||||
|
||||
const reducer = (state, action) => {
|
||||
switch (action.type) {
|
||||
case 'setSelectedItems':
|
||||
return {
|
||||
...state,
|
||||
selectedItems: action.payload,
|
||||
suggestions: [],
|
||||
selectedItems: action.payload.selectedItems,
|
||||
suggestions: action.payload.suggestions ?? state.suggestions,
|
||||
activeDescendentIndex: null,
|
||||
};
|
||||
case 'setSuggestions':
|
||||
|
|
@ -42,19 +43,56 @@ const reducer = (state, action) => {
|
|||
return { ...state, activeDescendentIndex: action.payload };
|
||||
case 'setIgnoreBlur':
|
||||
return { ...state, ignoreBlur: action.payload };
|
||||
case 'setShowMaxSelectionsReached':
|
||||
return { ...state, showMaxSelectionsReached: action.payload };
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
};
|
||||
|
||||
export const MultiSelectAutocomplete = ({ labelText, fetchSuggestions }) => {
|
||||
/**
|
||||
* Component allowing users to search and select multiple values
|
||||
*
|
||||
* @param {Object} props
|
||||
* @param {string} props.labelText The text for the input's label
|
||||
* @param {boolean} props.showLabel Whether the label text should be visible or hidden (for assistive tech users only)
|
||||
* @param {Function} props.fetchSuggestions Callback function which accepts the search term string and returns an array of suggestions
|
||||
* @param {Array} props.defaultValue Array of items which should be selected by default
|
||||
* @param {Array} props.staticSuggestions Array of items which should be suggested if no search term has been entered yet
|
||||
* @param {string} props.staticSuggestionsHeading Optional heading to display when static suggestions are rendered
|
||||
* @param {boolean} props.border Whether to show a bordered input
|
||||
* @param {string} props.placeholder Input placeholder text
|
||||
* @param {string} props.inputId ID to be applied to the input element
|
||||
* @param {number} props.maxSelections Optional maximum number of allowed selections
|
||||
* @param {Function} props.onSelectionsChanged Callback for when selections are added or removed
|
||||
* @param {Function} props.onFocus Callback for when the component receives focus
|
||||
* @param {Function} props.SuggestionTemplate Optional Preact component to render suggestion items
|
||||
* @param {Function} props.SelectionTemplate Optional Preact component to render selected items
|
||||
*/
|
||||
export const MultiSelectAutocomplete = ({
|
||||
labelText,
|
||||
showLabel = true,
|
||||
fetchSuggestions,
|
||||
defaultValue = [],
|
||||
staticSuggestions = [],
|
||||
staticSuggestionsHeading,
|
||||
border = true,
|
||||
placeholder = 'Add...',
|
||||
inputId,
|
||||
maxSelections,
|
||||
onSelectionsChanged,
|
||||
onFocus,
|
||||
SuggestionTemplate,
|
||||
SelectionTemplate = DefaultSelectionTemplate,
|
||||
}) => {
|
||||
const [state, dispatch] = useReducer(reducer, {
|
||||
suggestions: [],
|
||||
selectedItems: [],
|
||||
selectedItems: defaultValue,
|
||||
inputPosition: null,
|
||||
editValue: '',
|
||||
editValue: null,
|
||||
activeDescendentIndex: null,
|
||||
ignoreBlur: false,
|
||||
showMaxSelectionsReached: false,
|
||||
});
|
||||
|
||||
const {
|
||||
|
|
@ -64,53 +102,131 @@ export const MultiSelectAutocomplete = ({ labelText, fetchSuggestions }) => {
|
|||
editValue,
|
||||
activeDescendentIndex,
|
||||
ignoreBlur,
|
||||
showMaxSelectionsReached,
|
||||
} = state;
|
||||
|
||||
const inputRef = useRef(null);
|
||||
const inputSizerRef = useRef(null);
|
||||
const selectedItemsRef = useRef(null);
|
||||
const popoverRef = useRef(null);
|
||||
|
||||
const allowSelections =
|
||||
!maxSelections || selectedItems.length < maxSelections;
|
||||
|
||||
useEffect(() => {
|
||||
if (defaultValue.length > 0) {
|
||||
dispatch({
|
||||
type: 'setSelectedItems',
|
||||
payload: {
|
||||
selectedItems: defaultValue,
|
||||
},
|
||||
});
|
||||
}
|
||||
}, [defaultValue]);
|
||||
|
||||
const handleInputBlur = () => {
|
||||
// Since the input is sometimes removed and rendered in a new location on blur, it's possible that inputRef.current may be null when we complete this check.
|
||||
const currentValue = inputRef.current ? inputRef.current.value : '';
|
||||
dispatch({ type: 'setShowMaxSelectionsReached', payload: false });
|
||||
|
||||
const {
|
||||
current: { value: currentValue },
|
||||
} = inputRef;
|
||||
|
||||
// The input will blur when user selects an option from the dropdown via mouse click. The ignoreBlur boolean lets us know we can ignore this event.
|
||||
if (!ignoreBlur && currentValue !== '') {
|
||||
selectItem({ selectedItem: currentValue, focusInput: false });
|
||||
} else {
|
||||
if (!ignoreBlur && allowSelections && currentValue !== '') {
|
||||
selectByText({ textValue: currentValue, keepSelecting: false });
|
||||
return;
|
||||
}
|
||||
if (!ignoreBlur) {
|
||||
// Clear the suggestions if a genuine blur event
|
||||
dispatch({ type: 'setSuggestions', payload: [] });
|
||||
}
|
||||
|
||||
exitEditState({ keepSelecting: false });
|
||||
dispatch({ type: 'setIgnoreBlur', payload: false });
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
// editValue defaults to null when component is first rendered.
|
||||
// This ensures we do not autofocus the input before the user has started interacting with the component.
|
||||
if (editValue === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { current: input } = inputRef;
|
||||
if (inputPosition !== null) {
|
||||
if (input && inputPosition !== null) {
|
||||
// Entering 'edit' mode
|
||||
resizeInputToContentSize();
|
||||
|
||||
input.value = editValue;
|
||||
const { length: cursorPosition } = editValue;
|
||||
|
||||
input.focus();
|
||||
input.setSelectionRange(cursorPosition, cursorPosition);
|
||||
} else {
|
||||
// Remove inline style added to size the input
|
||||
input.style.width = '';
|
||||
input.focus();
|
||||
|
||||
// Trigger the input event to make sure suggestion UI updates correctly
|
||||
const changeEvent = new Event('input');
|
||||
input.dispatchEvent(changeEvent);
|
||||
}
|
||||
}, [inputPosition, editValue]);
|
||||
|
||||
useEffect(() => {
|
||||
if (activeDescendentIndex !== null) {
|
||||
const { current: popover } = popoverRef;
|
||||
const activeItem = popover?.querySelector('[aria-selected="true"]');
|
||||
if (!popover || !activeItem) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Make sure that the active item is scrolled into view, if need be
|
||||
const { offsetHeight, offsetTop } = activeItem;
|
||||
const { offsetHeight: popoverOffsetHeight, scrollTop } = popover;
|
||||
|
||||
const isAbove = offsetTop < scrollTop;
|
||||
const isBelow =
|
||||
offsetTop + offsetHeight > scrollTop + popoverOffsetHeight;
|
||||
|
||||
if (isAbove) {
|
||||
popover.scrollTo(0, offsetTop);
|
||||
} else if (isBelow) {
|
||||
popover.scrollTo(0, offsetTop - popoverOffsetHeight + offsetHeight);
|
||||
}
|
||||
}
|
||||
}, [activeDescendentIndex]);
|
||||
|
||||
const selectByText = ({
|
||||
textValue,
|
||||
nextInputValue = '',
|
||||
keepSelecting = true,
|
||||
}) => {
|
||||
const matchingSuggestion = suggestions.find(
|
||||
(suggestion) => suggestion.name === textValue,
|
||||
);
|
||||
selectItem({
|
||||
selectedItem: matchingSuggestion
|
||||
? matchingSuggestion
|
||||
: { name: textValue },
|
||||
nextInputValue,
|
||||
keepSelecting,
|
||||
});
|
||||
};
|
||||
|
||||
const enterEditState = (editItem, editItemIndex) => {
|
||||
inputSizerRef.current.innerText = editItem;
|
||||
inputSizerRef.current.innerText = editItem.name;
|
||||
deselectItem(editItem);
|
||||
|
||||
dispatch({
|
||||
type: 'updateEditState',
|
||||
payload: { editValue: editItem, inputPosition: editItemIndex },
|
||||
payload: {
|
||||
editValue: editItem.name,
|
||||
inputPosition: editItemIndex,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const exitEditState = (nextInputValue = '') => {
|
||||
const exitEditState = ({ nextInputValue = '', keepSelecting = true }) => {
|
||||
// Reset 'edit mode' input resizing
|
||||
inputRef.current?.style?.removeProperty('width');
|
||||
inputSizerRef.current.innerText = nextInputValue;
|
||||
|
||||
dispatch({
|
||||
type: 'updateEditState',
|
||||
payload: {
|
||||
|
|
@ -118,24 +234,88 @@ export const MultiSelectAutocomplete = ({ labelText, fetchSuggestions }) => {
|
|||
inputPosition: nextInputValue === '' ? null : inputPosition + 1,
|
||||
},
|
||||
});
|
||||
|
||||
// Blurring away while clearing the input
|
||||
if (!keepSelecting && nextInputValue === '') {
|
||||
inputRef.current.value = '';
|
||||
}
|
||||
};
|
||||
|
||||
const resizeInputToContentSize = () => {
|
||||
inputRef.current.style.width = `${inputSizerRef.current.clientWidth}px`;
|
||||
const { current: input } = inputRef;
|
||||
|
||||
if (input) {
|
||||
input.style.width = `${inputSizerRef.current.clientWidth}px`;
|
||||
}
|
||||
};
|
||||
|
||||
const handleAutocompleteStart = () => {
|
||||
// Only show static suggestions when not in edit mode
|
||||
if (inputPosition !== null) {
|
||||
return;
|
||||
}
|
||||
|
||||
// If we've already reached max selections, show the message rather than static suggestions
|
||||
if (!allowSelections) {
|
||||
dispatch({ type: 'setShowMaxSelectionsReached', payload: true });
|
||||
return;
|
||||
}
|
||||
|
||||
// If we have static suggestions, and no search term, show the static suggestions
|
||||
if (staticSuggestions.length > 0 && inputRef.current?.value === '') {
|
||||
dispatch({
|
||||
type: 'setSuggestions',
|
||||
payload: staticSuggestions.filter(
|
||||
(item) =>
|
||||
!selectedItems.some(
|
||||
(selectedItem) => selectedItem.name === item.name,
|
||||
),
|
||||
),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleInputChange = async ({ target: { value } }) => {
|
||||
// When the input appears inline in "edit" mode, we need to dynamically calculate the width to ensure it occupies the right space
|
||||
// (an input cannot resize based on its text content). We use a hidden <span> to track the size.
|
||||
inputSizerRef.current.innerText = value;
|
||||
|
||||
if (inputPosition !== null) {
|
||||
resizeInputToContentSize();
|
||||
}
|
||||
|
||||
// If max selections have already been reached, no need to fetch further suggestions
|
||||
if (!allowSelections) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (value === '') {
|
||||
dispatch({
|
||||
type: 'setSuggestions',
|
||||
payload: staticSuggestions.filter(
|
||||
(item) =>
|
||||
!selectedItems.some(
|
||||
(selectedItem) => selectedItem.name === item.name,
|
||||
),
|
||||
),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const results = await fetchSuggestions(value);
|
||||
// If no results, display current search term as an option
|
||||
if (results.length === 0 && value !== '') {
|
||||
results.push({ name: value });
|
||||
}
|
||||
|
||||
dispatch({
|
||||
type: 'setSuggestions',
|
||||
payload: results.filter((item) => !selectedItems.includes(item)),
|
||||
payload: results.filter(
|
||||
(item) =>
|
||||
!selectedItems.some(
|
||||
(selectedItem) => selectedItem.name === item.name,
|
||||
),
|
||||
),
|
||||
});
|
||||
};
|
||||
|
||||
|
|
@ -191,9 +371,9 @@ export const MultiSelectAutocomplete = ({ labelText, fetchSuggestions }) => {
|
|||
e.preventDefault();
|
||||
// Accept whatever is in the input before the comma or space.
|
||||
// If any text remains after the comma or space, the edit will continue separately
|
||||
if (currentValue !== '') {
|
||||
selectItem({
|
||||
selectedItem: currentValue.slice(0, selectionStart),
|
||||
if (currentValue !== '' && allowSelections) {
|
||||
selectByText({
|
||||
textValue: currentValue.slice(0, selectionStart),
|
||||
nextInputValue: currentValue.slice(selectionStart),
|
||||
});
|
||||
}
|
||||
|
|
@ -223,13 +403,26 @@ export const MultiSelectAutocomplete = ({ labelText, fetchSuggestions }) => {
|
|||
}
|
||||
};
|
||||
|
||||
const getEmptyInputSuggestions = ({ currentSelections = selectedItems }) => {
|
||||
if (staticSuggestions.length > 0) {
|
||||
return staticSuggestions.filter(
|
||||
(suggestion) =>
|
||||
!currentSelections.some(
|
||||
(selection) => selection.name === suggestion.name,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return [];
|
||||
};
|
||||
|
||||
const selectItem = ({
|
||||
selectedItem,
|
||||
nextInputValue = '',
|
||||
focusInput = true,
|
||||
keepSelecting = true,
|
||||
}) => {
|
||||
// If a user has manually typed an item already selected, reset
|
||||
if (selectedItems.includes(selectedItem)) {
|
||||
if (selectedItems.some((item) => item.name === selectedItem.name)) {
|
||||
clearInput();
|
||||
return;
|
||||
}
|
||||
|
|
@ -245,79 +438,98 @@ export const MultiSelectAutocomplete = ({ labelText, fetchSuggestions }) => {
|
|||
|
||||
// We update the hidden selected items list, so additions are announced to screen reader users
|
||||
const listItem = document.createElement('li');
|
||||
listItem.innerText = selectedItem;
|
||||
listItem.innerText = selectedItem.name;
|
||||
selectedItemsRef.current.appendChild(listItem);
|
||||
|
||||
exitEditState(nextInputValue);
|
||||
dispatch({ type: 'setSelectedItems', payload: newSelections });
|
||||
exitEditState({ nextInputValue, keepSelecting });
|
||||
|
||||
dispatch({
|
||||
type: 'setSelectedItems',
|
||||
payload: {
|
||||
selectedItems: newSelections,
|
||||
suggestions: keepSelecting
|
||||
? getEmptyInputSuggestions({
|
||||
currentSelections: newSelections,
|
||||
})
|
||||
: [],
|
||||
},
|
||||
});
|
||||
|
||||
onSelectionsChanged?.(newSelections);
|
||||
|
||||
// Clear the text input
|
||||
const { current: input } = inputRef;
|
||||
input.value = nextInputValue;
|
||||
focusInput && input.focus();
|
||||
|
||||
if (keepSelecting) {
|
||||
dispatch({
|
||||
type: 'setShowMaxSelectionsReached',
|
||||
payload: maxSelections && newSelections.length >= maxSelections,
|
||||
});
|
||||
|
||||
// setTimeout is used with no delay here to make sure this focus event is executed in the next event cycle.
|
||||
// selectItem() happens on mousedown rather than click, because mousedown is handled before the blur event, and we
|
||||
// want to ignore some blur events (i.e. when input blurs because user has clicked a dropdown option).
|
||||
// By using setTimeout, we make sure that the normal blur event is handled before we try to refocus the input.
|
||||
setTimeout(() => {
|
||||
input.focus();
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const deselectItem = (deselectedItem) => {
|
||||
const newSelections = selectedItems.filter(
|
||||
(item) => item.name !== deselectedItem.name,
|
||||
);
|
||||
dispatch({
|
||||
type: 'setSelectedItems',
|
||||
payload: selectedItems.filter((item) => item !== deselectedItem),
|
||||
payload: {
|
||||
selectedItems: newSelections,
|
||||
suggestions,
|
||||
},
|
||||
});
|
||||
|
||||
dispatch({
|
||||
type: 'setShowMaxSelectionsReached',
|
||||
payload: maxSelections && newSelections.length >= maxSelections,
|
||||
});
|
||||
|
||||
onSelectionsChanged?.(newSelections);
|
||||
|
||||
// We also update the hidden selected items list, so removals are announced to screen reader users
|
||||
selectedItemsRef.current.querySelectorAll('li').forEach((selectionNode) => {
|
||||
if (selectionNode.innerText === deselectedItem) {
|
||||
if (selectionNode.innerText === deselectedItem.name) {
|
||||
selectionNode.remove();
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const allSelectedItemElements = selectedItems.map((item, index) => (
|
||||
<li key={item} className="w-max">
|
||||
<div role="group" aria-label={item} className="flex mr-1 mb-1 w-max">
|
||||
<Button
|
||||
variant="secondary"
|
||||
className="c-autocomplete--multi__selected p-1 cursor-text"
|
||||
aria-label={`Edit ${item}`}
|
||||
onClick={() => enterEditState(item, index)}
|
||||
>
|
||||
{item}
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
className="c-autocomplete--multi__selected p-1"
|
||||
aria-label={`Remove ${item}`}
|
||||
onClick={() => deselectItem(item)}
|
||||
>
|
||||
<Icon src={Close} />
|
||||
</Button>
|
||||
</div>
|
||||
</li>
|
||||
));
|
||||
const allSelectedItemElements = selectedItems.map((item, index) => {
|
||||
// When we are in "edit mode" we visually display the input between the other selections
|
||||
const defaultPosition = index + 1;
|
||||
const appearsBeforeInput = inputPosition === null || index < inputPosition;
|
||||
const position = appearsBeforeInput ? defaultPosition : defaultPosition + 1;
|
||||
|
||||
// When a user edits a tag, we need to move the input inside the selected items
|
||||
const splitSelectionsAt =
|
||||
inputPosition !== null ? inputPosition : selectedItems.length;
|
||||
const { name: displayName } = item;
|
||||
return (
|
||||
<li
|
||||
key={displayName}
|
||||
className="c-autocomplete--multi__selection-list-item w-max"
|
||||
style={{ order: position }}
|
||||
>
|
||||
<SelectionTemplate
|
||||
{...item}
|
||||
onEdit={() => enterEditState(item, index)}
|
||||
onDeselect={() => deselectItem(item)}
|
||||
/>
|
||||
</li>
|
||||
);
|
||||
});
|
||||
|
||||
const input = (
|
||||
<li className="self-center">
|
||||
<input
|
||||
ref={inputRef}
|
||||
autocomplete="off"
|
||||
className="c-autocomplete--multi__input"
|
||||
aria-activedescendant={
|
||||
activeDescendentIndex !== null
|
||||
? suggestions[activeDescendentIndex]
|
||||
: null
|
||||
}
|
||||
aria-autocomplete="list"
|
||||
aria-labelledby="multi-select-label selected-items-list"
|
||||
type="text"
|
||||
onChange={handleInputChange}
|
||||
onKeyDown={handleKeyDown}
|
||||
onBlur={handleInputBlur}
|
||||
/>
|
||||
</li>
|
||||
);
|
||||
const selectionsPlaceholder =
|
||||
selectedItems.length > 0 ? PLACEHOLDER_SELECTIONS_MADE : placeholder;
|
||||
|
||||
const inputPlaceholder = allowSelections ? selectionsPlaceholder : null;
|
||||
|
||||
return (
|
||||
<Fragment>
|
||||
|
|
@ -326,7 +538,15 @@ export const MultiSelectAutocomplete = ({ labelText, fetchSuggestions }) => {
|
|||
aria-hidden="true"
|
||||
className="absolute pointer-events-none opacity-0 p-2"
|
||||
/>
|
||||
<label id="multi-select-label">{labelText}</label>
|
||||
<label
|
||||
id="multi-select-label"
|
||||
className={showLabel ? '' : 'screen-reader-only'}
|
||||
>
|
||||
{labelText}
|
||||
</label>
|
||||
<span id="input-description" className="screen-reader-only">
|
||||
{maxSelections ? `Maximum ${maxSelections} selections` : ''}
|
||||
</span>
|
||||
|
||||
{/* A visually hidden list provides confirmation messages to screen reader users as an item is selected or removed */}
|
||||
<div className="screen-reader-only">
|
||||
|
|
@ -348,43 +568,112 @@ export const MultiSelectAutocomplete = ({ labelText, fetchSuggestions }) => {
|
|||
aria-haspopup="listbox"
|
||||
aria-expanded={suggestions.length > 0}
|
||||
aria-owns="listbox1"
|
||||
className="c-autocomplete--multi__wrapper flex items-center crayons-textfield cursor-text"
|
||||
onClick={() => inputRef.current.focus()}
|
||||
className={`c-autocomplete--multi__wrapper${
|
||||
border ? '-border crayons-textfield' : ' border-none p-0'
|
||||
} flex items-center cursor-text`}
|
||||
onClick={() => inputRef.current?.focus()}
|
||||
>
|
||||
<ul id="combo-selected" className="list-none flex flex-wrap w-100">
|
||||
{allSelectedItemElements.slice(0, splitSelectionsAt)}
|
||||
{inputPosition !== null && input}
|
||||
{allSelectedItemElements.slice(splitSelectionsAt)}
|
||||
{inputPosition === null && input}
|
||||
{allSelectedItemElements}
|
||||
|
||||
<li
|
||||
className="self-center"
|
||||
style={{
|
||||
order:
|
||||
inputPosition === null
|
||||
? selectedItems.length + 1
|
||||
: inputPosition + 1,
|
||||
}}
|
||||
>
|
||||
<input
|
||||
id={inputId}
|
||||
ref={inputRef}
|
||||
autocomplete="off"
|
||||
className="c-autocomplete--multi__input"
|
||||
aria-activedescendant={
|
||||
activeDescendentIndex !== null
|
||||
? suggestions[activeDescendentIndex]
|
||||
: null
|
||||
}
|
||||
aria-autocomplete="list"
|
||||
aria-labelledby="multi-select-label selected-items-list"
|
||||
aria-describedby="input-description"
|
||||
aria-disabled={!allowSelections}
|
||||
type="text"
|
||||
onChange={handleInputChange}
|
||||
onKeyDown={handleKeyDown}
|
||||
onBlur={handleInputBlur}
|
||||
onFocus={(e) => {
|
||||
onFocus?.(e);
|
||||
handleAutocompleteStart();
|
||||
}}
|
||||
placeholder={inputPosition === null ? inputPlaceholder : null}
|
||||
/>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
{suggestions.length > 0 ? (
|
||||
<ul
|
||||
className="c-autocomplete--multi__popover"
|
||||
aria-labelledby="multi-select-label"
|
||||
role="listbox"
|
||||
aria-multiselectable="true"
|
||||
id="listbox1"
|
||||
>
|
||||
{suggestions.map((suggestion, index) => (
|
||||
// Focus remains in the input during keyboard use, and event handler is attached to that input
|
||||
// eslint-disable-next-line jsx-a11y/click-events-have-key-events
|
||||
<li
|
||||
id={suggestion}
|
||||
role="option"
|
||||
aria-selected={index === activeDescendentIndex}
|
||||
key={suggestion}
|
||||
onClick={() => selectItem({ selectedItem: suggestion })}
|
||||
onMouseDown={() =>
|
||||
dispatch({ type: 'setIgnoreBlue', payload: true })
|
||||
}
|
||||
>
|
||||
{suggestion}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
{showMaxSelectionsReached ? (
|
||||
<div className="c-autocomplete--multi__popover">
|
||||
<span className="p-3">Only {maxSelections} selections allowed</span>
|
||||
</div>
|
||||
) : null}
|
||||
{suggestions.length > 0 && allowSelections ? (
|
||||
<div className="c-autocomplete--multi__popover" ref={popoverRef}>
|
||||
{inputRef.current?.value === '' ? staticSuggestionsHeading : null}
|
||||
<ul
|
||||
className="list-none"
|
||||
aria-labelledby="multi-select-label"
|
||||
role="listbox"
|
||||
aria-multiselectable="true"
|
||||
id="listbox1"
|
||||
>
|
||||
{suggestions.map((suggestion, index) => {
|
||||
const { name: suggestionDisplayName } = suggestion;
|
||||
return (
|
||||
<li
|
||||
id={suggestionDisplayName}
|
||||
role="option"
|
||||
aria-selected={index === activeDescendentIndex}
|
||||
key={suggestionDisplayName}
|
||||
onMouseDown={() => {
|
||||
selectItem({ selectedItem: suggestion });
|
||||
dispatch({ type: 'setIgnoreBlur', payload: true });
|
||||
}}
|
||||
>
|
||||
{SuggestionTemplate ? (
|
||||
<SuggestionTemplate {...suggestion} />
|
||||
) : (
|
||||
suggestionDisplayName
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</Fragment>
|
||||
);
|
||||
};
|
||||
|
||||
const optionPropType = PropTypes.shape({ name: PropTypes.string });
|
||||
|
||||
MultiSelectAutocomplete.propTypes = {
|
||||
labelText: PropTypes.string.isRequired,
|
||||
showLabel: PropTypes.bool,
|
||||
fetchSuggestions: PropTypes.func.isRequired,
|
||||
defaultValue: PropTypes.arrayOf(optionPropType),
|
||||
staticSuggestions: PropTypes.arrayOf(optionPropType),
|
||||
staticSuggestionsHeading: PropTypes.oneOfType([
|
||||
PropTypes.element,
|
||||
PropTypes.string,
|
||||
]),
|
||||
border: PropTypes.bool,
|
||||
placeholder: PropTypes.string,
|
||||
inputId: PropTypes.string,
|
||||
maxSelections: PropTypes.number,
|
||||
onSelectionsChanged: PropTypes.func,
|
||||
onFocus: PropTypes.func,
|
||||
SuggestionTemplate: PropTypes.func,
|
||||
SelectionTemplate: PropTypes.func,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -0,0 +1,60 @@
|
|||
# Multi-select Autocomplete
|
||||
|
||||
The MultiSelectAutocomplete component can be used in situations where a user can search for matching options, and select multiple values.
|
||||
|
||||
When the user starts typing, an async search will be triggered, and any fetched suggestions displayed in the dropdown. If no suggestions are available, the currently entered search term will be displayed as an option.
|
||||
|
||||
## Browsing and selecting an option
|
||||
|
||||
Options can be selected in a few ways:
|
||||
|
||||
- By clicking an option from the dropdown
|
||||
- By using the up/down arrow keys to highlight an option in the dropdown, then pressing `Enter`
|
||||
- By typing a term, and then pressing `Spacebar`, `Comma`, or leaving the input completely
|
||||
|
||||
## Editing and removing selections
|
||||
|
||||
When a user selects a suggestion, it will be displayed as a button group. The first button triggers an edit of the selection, and the second button triggers the removal of the selection.
|
||||
|
||||
Both buttons appear in the tab order, and are semantically grouped together for assistive technology.
|
||||
|
||||
## Technical implementation notes
|
||||
|
||||
### Search functionality
|
||||
|
||||
A `fetchSuggestions` prop **must** be passed in props to the component. This callback will receive the search term, and expects results to be returned in the format:
|
||||
|
||||
```js
|
||||
[{ name: 'option one' }, { name: 'option two' }];
|
||||
```
|
||||
|
||||
i.e. suggestions and selections are expected as `object`s with a `name` attribute.
|
||||
|
||||
### Static suggestions
|
||||
|
||||
If there are some default suggestions that should be shown to the user before they have started typing their search term, these may be passed using the `staticSuggestions` prop.
|
||||
|
||||
Additionally, if you would like explanatory text to be displayed at the top of these static suggestions, it can be passed in the `staticSuggestionsHeading` prop. This prop can be a string, or an HTML element (e.g. in case you want to use a heading or a `span` with specific styles applied).
|
||||
|
||||
### Maximum selections
|
||||
|
||||
A maximum can be set on the number of selections a user can make, by passing the `maxSuggestions` prop (the default is unlimited). When a user has reached the maximum number of selections, a message will be displayed, and the input's `aria-disabled` property will be set to 'true'.
|
||||
|
||||
The maximum number of selections possible is also added to the accessible input description via a hidden span and `aria-describedby`.
|
||||
|
||||
### Styling
|
||||
|
||||
The component comes with some default styling for both suggested options, and selected options, but the layout of both suggestions and selections can be customised by passing in a Preact component to render them, i.e.:
|
||||
|
||||
- SuggestionTemplate
|
||||
- SelectionTemplate
|
||||
|
||||
If either template is passed, it will receive in props the full option object in props e.g. `{ name: 'option one', someOtherProperty: 'foo' }`.
|
||||
|
||||
The overall component may be used with or without a border depending on its context (if in doubt, check with your designer), by passing the `border` prop.
|
||||
|
||||
### Accessibility
|
||||
|
||||
The component follows the [WAI-ARIA Combobox authoring practices](https://www.w3.org/TR/wai-aria-practices-1.1/#combobox) to ensure operability for a wide range of users.
|
||||
|
||||
Selected items appear as a button group with further context provided in `aria-label`s for screen reader users (Edit/Remove). As selections are made from the list, or removed from the selected items, they are announced to screen reader users via a visually hidden `aria-live` region.
|
||||
|
|
@ -1,42 +1,80 @@
|
|||
import { h } from 'preact';
|
||||
import { MultiSelectAutocomplete } from '../MultiSelectAutocomplete';
|
||||
import MultiSelectAutocompleteDoc from './MultiSelectAutocomplete.mdx';
|
||||
|
||||
export default {
|
||||
title: 'BETA/MultiSelectAutocomplete',
|
||||
title: 'App Components/MultiSelectAutocomplete',
|
||||
parameters: {
|
||||
docs: {
|
||||
page: MultiSelectAutocompleteDoc,
|
||||
},
|
||||
},
|
||||
argTypes: {
|
||||
border: {
|
||||
table: {
|
||||
defaultValue: { summary: false },
|
||||
},
|
||||
description: 'Display as a standard bordered input',
|
||||
},
|
||||
labelText: {
|
||||
description: 'The label for the input',
|
||||
},
|
||||
showLabel: {
|
||||
description:
|
||||
'Should the label text be visible (it will always be available to assistive technology regardless)',
|
||||
table: {
|
||||
defaultValue: { summary: true },
|
||||
},
|
||||
},
|
||||
placeholder: {
|
||||
description:
|
||||
'Placeholder text, shown when no selections have been made yet',
|
||||
},
|
||||
maxSelections: {
|
||||
description: 'Optional maximum number of selections that can be made',
|
||||
},
|
||||
staticSuggestionsHeading: {
|
||||
description:
|
||||
'Optional heading to show when static suggestions are shown (when user has not yet typed a search term). Accepts either a string or an HTML element.',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const Default = () => {
|
||||
export const Default = (args) => {
|
||||
const options = [
|
||||
'one',
|
||||
'two',
|
||||
'three',
|
||||
'four',
|
||||
'five',
|
||||
'six',
|
||||
'seven',
|
||||
'eight',
|
||||
'nine',
|
||||
'ten',
|
||||
{ name: 'one' },
|
||||
{ name: 'two' },
|
||||
{ name: 'three' },
|
||||
{ name: 'four' },
|
||||
{ name: 'five' },
|
||||
{ name: 'six' },
|
||||
{ name: 'seven' },
|
||||
{ name: 'eight' },
|
||||
{ name: 'nine' },
|
||||
{ name: 'ten' },
|
||||
];
|
||||
|
||||
const fetchSuggestions = async (searchTerm) => {
|
||||
const filteredSuggestions = options.filter((option) =>
|
||||
option.startsWith(searchTerm),
|
||||
);
|
||||
|
||||
return filteredSuggestions.length === 0
|
||||
? [searchTerm]
|
||||
: filteredSuggestions;
|
||||
};
|
||||
const fetchSuggestions = async (searchTerm) =>
|
||||
options.filter((option) => option.name.startsWith(searchTerm));
|
||||
|
||||
return (
|
||||
<MultiSelectAutocomplete
|
||||
labelText="Example multi select autocomplete"
|
||||
{...args}
|
||||
fetchSuggestions={fetchSuggestions}
|
||||
staticSuggestions={options.slice(0, 3)}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
Default.args = {
|
||||
border: true,
|
||||
labelText: 'Example multi select autocomplete',
|
||||
showLabel: true,
|
||||
placeholder: 'Add a number...',
|
||||
maxSelections: 4,
|
||||
staticSuggestionsHeading: 'Static suggestions',
|
||||
};
|
||||
|
||||
Default.story = {
|
||||
name: 'default',
|
||||
};
|
||||
|
|
|
|||
|
|
@ -0,0 +1,557 @@
|
|||
import { h } from 'preact';
|
||||
import { render, waitFor } from '@testing-library/preact';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
|
||||
import '@testing-library/jest-dom';
|
||||
|
||||
import { MultiSelectAutocomplete } from '../MultiSelectAutocomplete';
|
||||
|
||||
describe('<MultiSelectAutocomplete />', () => {
|
||||
it('renders default UI', () => {
|
||||
const { container } = render(
|
||||
<MultiSelectAutocomplete
|
||||
labelText="Example label"
|
||||
fetchSuggestions={() => {}}
|
||||
/>,
|
||||
);
|
||||
expect(container.innerHTML).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('renders with customisation', () => {
|
||||
const { container } = render(
|
||||
<MultiSelectAutocomplete
|
||||
labelText="Example label"
|
||||
showLabel={false}
|
||||
border={false}
|
||||
placeholder="Example placeholder"
|
||||
inputId="example-input-id"
|
||||
fetchSuggestions={() => {}}
|
||||
/>,
|
||||
);
|
||||
expect(container.innerHTML).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('renders with default values', () => {
|
||||
const { container } = render(
|
||||
<MultiSelectAutocomplete
|
||||
labelText="Example label"
|
||||
defaultValue={[{ name: 'Default one' }, { name: 'Default two' }]}
|
||||
fetchSuggestions={() => {}}
|
||||
/>,
|
||||
);
|
||||
expect(container.innerHTML).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('shows static suggestions on focus, if provided', async () => {
|
||||
const { getByLabelText, getByText, getByRole } = render(
|
||||
<MultiSelectAutocomplete
|
||||
labelText="Example label"
|
||||
staticSuggestions={[{ name: 'one' }, { name: 'two' }]}
|
||||
staticSuggestionsHeading="static suggestions heading"
|
||||
fetchSuggestions={() => {}}
|
||||
/>,
|
||||
);
|
||||
|
||||
getByLabelText('Example label').focus();
|
||||
|
||||
await waitFor(() =>
|
||||
expect(getByText('static suggestions heading')).toBeInTheDocument(),
|
||||
);
|
||||
|
||||
expect(getByRole('option', { name: 'one' })).toBeInTheDocument();
|
||||
expect(getByRole('option', { name: 'two' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('fetches suggestions and displays in dropdown', async () => {
|
||||
const mockFetchSuggestions = jest.fn(async () => [
|
||||
{ name: 'option1' },
|
||||
{ name: 'option2' },
|
||||
]);
|
||||
|
||||
const { getByLabelText, getByText, getByRole, queryByText } = render(
|
||||
<MultiSelectAutocomplete
|
||||
labelText="Example label"
|
||||
staticSuggestions={[{ name: 'one' }, { name: 'two' }]}
|
||||
staticSuggestionsHeading="static suggestions heading"
|
||||
fetchSuggestions={mockFetchSuggestions}
|
||||
/>,
|
||||
);
|
||||
|
||||
const input = getByLabelText('Example label');
|
||||
input.focus();
|
||||
|
||||
await waitFor(() =>
|
||||
expect(getByText('static suggestions heading')).toBeInTheDocument(),
|
||||
);
|
||||
|
||||
userEvent.type(input, 'a');
|
||||
|
||||
// Check static suggestions are gone and expected options have appeared
|
||||
await waitFor(() =>
|
||||
expect(queryByText('static suggestions heading')).toBeNull(),
|
||||
);
|
||||
expect(getByRole('option', { name: 'option1' })).toBeInTheDocument();
|
||||
expect(getByRole('option', { name: 'option2' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('displays suggestions in a custom template, if provided', async () => {
|
||||
const Suggestion = ({ name }) => <span>custom suggestion: {name}</span>;
|
||||
const mockFetchSuggestions = jest.fn(async () => [
|
||||
{ name: 'option1' },
|
||||
{ name: 'option2' },
|
||||
]);
|
||||
|
||||
const { getByLabelText, getByRole } = render(
|
||||
<MultiSelectAutocomplete
|
||||
labelText="Example label"
|
||||
fetchSuggestions={mockFetchSuggestions}
|
||||
SuggestionTemplate={Suggestion}
|
||||
/>,
|
||||
);
|
||||
|
||||
const input = getByLabelText('Example label');
|
||||
input.focus();
|
||||
userEvent.type(input, 'a');
|
||||
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
getByRole('option', { name: 'custom suggestion: option1' }),
|
||||
).toBeInTheDocument(),
|
||||
);
|
||||
expect(
|
||||
getByRole('option', { name: 'custom suggestion: option2' }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('displays search term as an option if no suggestions', async () => {
|
||||
const mockFetchSuggestions = jest.fn(async () => []);
|
||||
|
||||
const { getByLabelText, getByRole } = render(
|
||||
<MultiSelectAutocomplete
|
||||
labelText="Example label"
|
||||
fetchSuggestions={mockFetchSuggestions}
|
||||
/>,
|
||||
);
|
||||
|
||||
const input = getByLabelText('Example label');
|
||||
input.focus();
|
||||
userEvent.type(input, 'a');
|
||||
|
||||
await waitFor(() =>
|
||||
expect(getByRole('option', { name: 'a' })).toBeInTheDocument(),
|
||||
);
|
||||
});
|
||||
|
||||
it('selects an option by clicking', async () => {
|
||||
const mockFetchSuggestions = jest.fn(async () => [
|
||||
{ name: 'option1' },
|
||||
{ name: 'option2' },
|
||||
]);
|
||||
|
||||
const { getByLabelText, getByRole } = render(
|
||||
<MultiSelectAutocomplete
|
||||
labelText="Example label"
|
||||
fetchSuggestions={mockFetchSuggestions}
|
||||
/>,
|
||||
);
|
||||
|
||||
const input = getByLabelText('Example label');
|
||||
input.focus();
|
||||
userEvent.type(input, 'a');
|
||||
|
||||
await waitFor(() =>
|
||||
expect(getByRole('option', { name: 'option1' })).toBeInTheDocument(),
|
||||
);
|
||||
|
||||
userEvent.click(getByRole('option', { name: 'option1' }));
|
||||
|
||||
// It should now be added to the list of selected items
|
||||
await waitFor(() =>
|
||||
expect(getByRole('button', { name: 'Edit option1' })).toBeInTheDocument(),
|
||||
);
|
||||
expect(getByRole('button', { name: 'Remove option1' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('selects an option by keyboard enter press', async () => {
|
||||
const mockFetchSuggestions = jest.fn(async () => [
|
||||
{ name: 'option1' },
|
||||
{ name: 'option2' },
|
||||
]);
|
||||
|
||||
const { getByLabelText, getByRole } = render(
|
||||
<MultiSelectAutocomplete
|
||||
labelText="Example label"
|
||||
fetchSuggestions={mockFetchSuggestions}
|
||||
/>,
|
||||
);
|
||||
|
||||
const input = getByLabelText('Example label');
|
||||
input.focus();
|
||||
userEvent.type(input, 'a');
|
||||
|
||||
await waitFor(() =>
|
||||
expect(getByRole('option', { name: 'option1' })).toBeInTheDocument(),
|
||||
);
|
||||
|
||||
userEvent.type(input, '{arrowdown}');
|
||||
|
||||
await waitFor(() =>
|
||||
expect(getByRole('option', { name: 'option1' })).toHaveAttribute(
|
||||
'aria-selected',
|
||||
'true',
|
||||
),
|
||||
);
|
||||
userEvent.type(input, '{enter}');
|
||||
|
||||
// It should now be added to the list of selected items
|
||||
await waitFor(() =>
|
||||
expect(getByRole('button', { name: 'Edit option1' })).toBeInTheDocument(),
|
||||
);
|
||||
expect(getByRole('button', { name: 'Remove option1' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should select current text on spacebar press', async () => {
|
||||
const mockFetchSuggestions = jest.fn(async () => [
|
||||
{ name: 'option1' },
|
||||
{ name: 'option2' },
|
||||
]);
|
||||
|
||||
const { getByLabelText, getByRole } = render(
|
||||
<MultiSelectAutocomplete
|
||||
labelText="Example label"
|
||||
fetchSuggestions={mockFetchSuggestions}
|
||||
/>,
|
||||
);
|
||||
|
||||
const input = getByLabelText('Example label');
|
||||
input.focus();
|
||||
userEvent.type(input, 'example{space}');
|
||||
|
||||
// It should now be added to the list of selected items
|
||||
await waitFor(() =>
|
||||
expect(getByRole('button', { name: 'Edit example' })).toBeInTheDocument(),
|
||||
);
|
||||
expect(getByRole('button', { name: 'Remove example' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should select current text on comma press', async () => {
|
||||
const mockFetchSuggestions = jest.fn(async () => [
|
||||
{ name: 'option1' },
|
||||
{ name: 'option2' },
|
||||
]);
|
||||
|
||||
const { getByLabelText, getByRole } = render(
|
||||
<MultiSelectAutocomplete
|
||||
labelText="Example label"
|
||||
fetchSuggestions={mockFetchSuggestions}
|
||||
/>,
|
||||
);
|
||||
|
||||
const input = getByLabelText('Example label');
|
||||
input.focus();
|
||||
userEvent.type(input, 'example,');
|
||||
|
||||
// It should now be added to the list of selected items
|
||||
await waitFor(() =>
|
||||
expect(getByRole('button', { name: 'Edit example' })).toBeInTheDocument(),
|
||||
);
|
||||
expect(getByRole('button', { name: 'Remove example' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("doesn't select manual text entry if already selected", async () => {
|
||||
const { getByLabelText, getByRole, getAllByRole } = render(
|
||||
<MultiSelectAutocomplete
|
||||
labelText="Example label"
|
||||
defaultValue={[{ name: 'example' }]}
|
||||
fetchSuggestions={() => []}
|
||||
/>,
|
||||
);
|
||||
|
||||
// Item should already be selected, as passed as a default value
|
||||
expect(getByRole('button', { name: 'Edit example' })).toBeInTheDocument();
|
||||
|
||||
const input = getByLabelText('Example label');
|
||||
input.focus();
|
||||
// Try to select the same value by manually typing
|
||||
userEvent.type(input, 'example,');
|
||||
|
||||
expect(input).toHaveValue('');
|
||||
// Verify there is still only one selected 'example'
|
||||
expect(getAllByRole('button', { name: 'Edit example' })).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('displays selections in a custom template, if provided', async () => {
|
||||
const Selection = ({ name }) => <button>Selected: {name}</button>;
|
||||
|
||||
const mockFetchSuggestions = jest.fn(async () => [
|
||||
{ name: 'option1' },
|
||||
{ name: 'option2' },
|
||||
]);
|
||||
|
||||
const { getByLabelText, getByRole } = render(
|
||||
<MultiSelectAutocomplete
|
||||
labelText="Example label"
|
||||
fetchSuggestions={mockFetchSuggestions}
|
||||
SelectionTemplate={Selection}
|
||||
/>,
|
||||
);
|
||||
|
||||
const input = getByLabelText('Example label');
|
||||
input.focus();
|
||||
userEvent.type(input, 'example,');
|
||||
|
||||
// It should now be added to the list of selected items using the custom template
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
getByRole('button', { name: 'Selected: example' }),
|
||||
).toBeInTheDocument(),
|
||||
);
|
||||
});
|
||||
|
||||
it('edits a selection', async () => {
|
||||
const { getByLabelText, getByRole, queryByRole } = render(
|
||||
<MultiSelectAutocomplete
|
||||
labelText="Example label"
|
||||
fetchSuggestions={() => []}
|
||||
defaultValue={[{ name: 'one' }]}
|
||||
/>,
|
||||
);
|
||||
|
||||
getByRole('button', { name: 'Edit one' }).click();
|
||||
|
||||
// Selection disappears
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
queryByRole('button', { name: 'Edit one' }),
|
||||
).not.toBeInTheDocument(),
|
||||
);
|
||||
|
||||
// Input is focused and pre-filled with value
|
||||
const input = getByLabelText('Example label');
|
||||
await waitFor(() => expect(input).toHaveValue('one'));
|
||||
expect(input).toHaveFocus();
|
||||
});
|
||||
|
||||
it('passes an edit callback to custom selection template', async () => {
|
||||
const Selection = ({ name, onEdit }) => (
|
||||
<button onClick={onEdit}>Selected: {name}</button>
|
||||
);
|
||||
|
||||
const { getByLabelText, getByRole, queryByRole } = render(
|
||||
<MultiSelectAutocomplete
|
||||
labelText="Example label"
|
||||
defaultValue={[{ name: 'one' }]}
|
||||
fetchSuggestions={() => []}
|
||||
SelectionTemplate={Selection}
|
||||
/>,
|
||||
);
|
||||
|
||||
getByRole('button', { name: 'Selected: one' }).click();
|
||||
|
||||
// Selection disappears
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
queryByRole('button', { name: 'Selected: one' }),
|
||||
).not.toBeInTheDocument(),
|
||||
);
|
||||
|
||||
// Input is focused and pre-filled with value
|
||||
const input = getByLabelText('Example label');
|
||||
await waitFor(() => expect(input).toHaveValue('one'));
|
||||
expect(input).toHaveFocus();
|
||||
});
|
||||
|
||||
it('deletes a selection', async () => {
|
||||
const { getByLabelText, getByRole, queryByRole } = render(
|
||||
<MultiSelectAutocomplete
|
||||
labelText="Example label"
|
||||
fetchSuggestions={() => []}
|
||||
defaultValue={[{ name: 'one' }]}
|
||||
/>,
|
||||
);
|
||||
|
||||
getByRole('button', { name: 'Remove one' }).click();
|
||||
|
||||
// Selection disappears
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
queryByRole('button', { name: 'Remove one' }),
|
||||
).not.toBeInTheDocument(),
|
||||
);
|
||||
|
||||
// Input is re-focused
|
||||
expect(getByLabelText('Example label')).toHaveFocus();
|
||||
});
|
||||
|
||||
it('passes a deselect callback to custom selection template', async () => {
|
||||
const Selection = ({ name, onDeselect }) => (
|
||||
<button onClick={onDeselect}>Selected: {name}</button>
|
||||
);
|
||||
|
||||
const { getByLabelText, getByRole, queryByRole } = render(
|
||||
<MultiSelectAutocomplete
|
||||
labelText="Example label"
|
||||
defaultValue={[{ name: 'one' }]}
|
||||
fetchSuggestions={() => []}
|
||||
SelectionTemplate={Selection}
|
||||
/>,
|
||||
);
|
||||
|
||||
getByRole('button', { name: 'Selected: one' }).click();
|
||||
|
||||
// Selection disappears
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
queryByRole('button', { name: 'Selected: one' }),
|
||||
).not.toBeInTheDocument(),
|
||||
);
|
||||
|
||||
// Input is re-focused
|
||||
expect(getByLabelText('Example label')).toHaveFocus();
|
||||
});
|
||||
|
||||
it('closes dropdown and selects current input value on blur', async () => {
|
||||
const mockFetchSuggestions = jest.fn(async () => [
|
||||
{ name: 'option1' },
|
||||
{ name: 'option2' },
|
||||
]);
|
||||
|
||||
const { getByLabelText, getByRole, queryByRole } = render(
|
||||
<MultiSelectAutocomplete
|
||||
labelText="Example label"
|
||||
fetchSuggestions={mockFetchSuggestions}
|
||||
/>,
|
||||
);
|
||||
|
||||
const input = getByLabelText('Example label');
|
||||
input.focus();
|
||||
userEvent.type(input, 'a');
|
||||
|
||||
await waitFor(() =>
|
||||
expect(getByRole('option', { name: 'option1' })).toBeInTheDocument(),
|
||||
);
|
||||
|
||||
input.blur();
|
||||
|
||||
// Dropdown should no longer be visible
|
||||
await waitFor(() =>
|
||||
expect(queryByRole('option', { name: 'option1' })).toBeNull(),
|
||||
);
|
||||
|
||||
// Text value should be selected
|
||||
expect(getByRole('button', { name: 'Edit a' })).toBeInTheDocument();
|
||||
expect(getByRole('button', { name: 'Remove a' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('clears the input on Escape press', async () => {
|
||||
const mockFetchSuggestions = jest.fn(async () => [
|
||||
{ name: 'option1' },
|
||||
{ name: 'option2' },
|
||||
]);
|
||||
|
||||
const { getByLabelText, getByRole, queryByRole } = render(
|
||||
<MultiSelectAutocomplete
|
||||
labelText="Example label"
|
||||
fetchSuggestions={mockFetchSuggestions}
|
||||
/>,
|
||||
);
|
||||
|
||||
const input = getByLabelText('Example label');
|
||||
input.focus();
|
||||
userEvent.type(input, 'a');
|
||||
|
||||
await waitFor(() =>
|
||||
expect(getByRole('option', { name: 'option1' })).toBeInTheDocument(),
|
||||
);
|
||||
|
||||
userEvent.type(input, '{esc}');
|
||||
|
||||
await waitFor(() =>
|
||||
expect(queryByRole('option', { name: 'option1' })).toBeNull(),
|
||||
);
|
||||
|
||||
expect(input).toHaveValue('');
|
||||
});
|
||||
|
||||
it('Edits previous selection (if exists) on backspace press in empty input', async () => {
|
||||
const { getByLabelText, getByRole, queryByRole } = render(
|
||||
<MultiSelectAutocomplete
|
||||
labelText="Example label"
|
||||
defaultValue={[{ name: 'example' }]}
|
||||
fetchSuggestions={() => []}
|
||||
/>,
|
||||
);
|
||||
|
||||
// Selection should be present as passed as a default value
|
||||
expect(getByRole('button', { name: 'Edit example' })).toBeInTheDocument();
|
||||
|
||||
const input = getByLabelText('Example label');
|
||||
input.focus();
|
||||
userEvent.type(input, '{backspace}');
|
||||
|
||||
await waitFor(() => expect(input).toHaveValue('example'));
|
||||
expect(queryByRole('button', { name: 'Edit example' })).toBeNull();
|
||||
});
|
||||
|
||||
it('Prohibits entering special characters', () => {
|
||||
const { getByLabelText } = render(
|
||||
<MultiSelectAutocomplete
|
||||
labelText="Example label"
|
||||
fetchSuggestions={() => []}
|
||||
/>,
|
||||
);
|
||||
|
||||
const input = getByLabelText('Example label');
|
||||
userEvent.type(input, '!@£$%^&*()a1');
|
||||
expect(input).toHaveValue('a1');
|
||||
});
|
||||
|
||||
it('shows a message and prevents selections when maximum is reached', async () => {
|
||||
const mockFetchSuggestions = jest.fn(async () => [
|
||||
{ name: 'option1' },
|
||||
{ name: 'option2' },
|
||||
]);
|
||||
|
||||
const {
|
||||
getByLabelText,
|
||||
getByRole,
|
||||
queryByText,
|
||||
getByText,
|
||||
queryAllByRole,
|
||||
} = render(
|
||||
<MultiSelectAutocomplete
|
||||
maxSelections={2}
|
||||
defaultValue={[{ name: 'defaultSelection' }]}
|
||||
labelText="Example label"
|
||||
fetchSuggestions={mockFetchSuggestions}
|
||||
/>,
|
||||
);
|
||||
|
||||
const input = getByLabelText('Example label');
|
||||
input.focus();
|
||||
|
||||
// Max selections not yet reached
|
||||
expect(queryByText('Only 2 selections allowed')).toBeNull();
|
||||
expect(input).toHaveAttribute('aria-disabled', 'false');
|
||||
|
||||
userEvent.type(input, 'example,');
|
||||
|
||||
// Make sure option has been selected
|
||||
await waitFor(() => getByRole('button', { name: 'Edit example' }));
|
||||
|
||||
// Check input is in the max reached state
|
||||
expect(input).toHaveAttribute('placeholder', '');
|
||||
expect(getByText('Only 2 selections allowed')).toBeInTheDocument();
|
||||
expect(input).toHaveAttribute('aria-disabled', 'true');
|
||||
|
||||
// Start typing and make sure further options not shown
|
||||
userEvent.type(input, 'a');
|
||||
expect(queryAllByRole('option')).toHaveLength(0);
|
||||
expect(getByText('Only 2 selections allowed')).toBeInTheDocument();
|
||||
|
||||
// Try to select a value by typing a comma
|
||||
userEvent.type(input, 'b,');
|
||||
// Verify the selection wasn't made, and the text is still in the input
|
||||
expect(input).toHaveValue('ab');
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`<MultiSelectAutocomplete /> renders default UI 1`] = `"<span aria-hidden=\\"true\\" class=\\"absolute pointer-events-none opacity-0 p-2\\"></span><label id=\\"multi-select-label\\" class=\\"\\">Example label</label><span id=\\"input-description\\" class=\\"screen-reader-only\\"></span><div class=\\"screen-reader-only\\"><p>Selected items:</p><ul class=\\"screen-reader-only list-none\\" aria-live=\\"assertive\\" aria-atomic=\\"false\\" aria-relevant=\\"additions removals\\"></ul></div><div class=\\"c-autocomplete--multi relative\\"><div role=\\"combobox\\" aria-haspopup=\\"listbox\\" aria-expanded=\\"false\\" aria-owns=\\"listbox1\\" class=\\"c-autocomplete--multi__wrapper-border crayons-textfield flex items-center cursor-text\\"><ul id=\\"combo-selected\\" class=\\"list-none flex flex-wrap w-100\\"><li class=\\"self-center\\" style=\\"order: 1;\\"><input autocomplete=\\"off\\" class=\\"c-autocomplete--multi__input\\" aria-autocomplete=\\"list\\" aria-labelledby=\\"multi-select-label selected-items-list\\" aria-describedby=\\"input-description\\" aria-disabled=\\"false\\" type=\\"text\\" placeholder=\\"Add...\\"></li></ul></div></div>"`;
|
||||
|
||||
exports[`<MultiSelectAutocomplete /> renders with customisation 1`] = `"<span aria-hidden=\\"true\\" class=\\"absolute pointer-events-none opacity-0 p-2\\"></span><label id=\\"multi-select-label\\" class=\\"screen-reader-only\\">Example label</label><span id=\\"input-description\\" class=\\"screen-reader-only\\"></span><div class=\\"screen-reader-only\\"><p>Selected items:</p><ul class=\\"screen-reader-only list-none\\" aria-live=\\"assertive\\" aria-atomic=\\"false\\" aria-relevant=\\"additions removals\\"></ul></div><div class=\\"c-autocomplete--multi relative\\"><div role=\\"combobox\\" aria-haspopup=\\"listbox\\" aria-expanded=\\"false\\" aria-owns=\\"listbox1\\" class=\\"c-autocomplete--multi__wrapper border-none p-0 flex items-center cursor-text\\"><ul id=\\"combo-selected\\" class=\\"list-none flex flex-wrap w-100\\"><li class=\\"self-center\\" style=\\"order: 1;\\"><input id=\\"example-input-id\\" autocomplete=\\"off\\" class=\\"c-autocomplete--multi__input\\" aria-autocomplete=\\"list\\" aria-labelledby=\\"multi-select-label selected-items-list\\" aria-describedby=\\"input-description\\" aria-disabled=\\"false\\" type=\\"text\\" placeholder=\\"Example placeholder\\"></li></ul></div></div>"`;
|
||||
|
||||
exports[`<MultiSelectAutocomplete /> renders with default values 1`] = `"<span aria-hidden=\\"true\\" class=\\"absolute pointer-events-none opacity-0 p-2\\"></span><label id=\\"multi-select-label\\" class=\\"\\">Example label</label><span id=\\"input-description\\" class=\\"screen-reader-only\\"></span><div class=\\"screen-reader-only\\"><p>Selected items:</p><ul class=\\"screen-reader-only list-none\\" aria-live=\\"assertive\\" aria-atomic=\\"false\\" aria-relevant=\\"additions removals\\"></ul></div><div class=\\"c-autocomplete--multi relative\\"><div role=\\"combobox\\" aria-haspopup=\\"listbox\\" aria-expanded=\\"false\\" aria-owns=\\"listbox1\\" class=\\"c-autocomplete--multi__wrapper-border crayons-textfield flex items-center cursor-text\\"><ul id=\\"combo-selected\\" class=\\"list-none flex flex-wrap w-100\\"><li class=\\"c-autocomplete--multi__selection-list-item w-max\\" style=\\"order: 1;\\"><div role=\\"group\\" aria-label=\\"Default one\\" class=\\"flex mr-1 mb-1 w-max\\"><button type=\\"button\\" class=\\"c-btn c-btn--secondary c-autocomplete--multi__selected p-1 cursor-text\\" aria-label=\\"Edit Default one\\">Default one</button><button type=\\"button\\" class=\\"c-btn c-btn--secondary c-autocomplete--multi__selected p-1\\" aria-label=\\"Remove Default one\\"><svg class=\\"crayons-icon\\" width=\\"24\\" height=\\"24\\" xmlns=\\"http://www.w3.org/2000/svg\\"><path d=\\"m12 10.586 4.95-4.95 1.414 1.414-4.95 4.95 4.95 4.95-1.414 1.414-4.95-4.95-4.95 4.95-1.414-1.414 4.95-4.95-4.95-4.95L7.05 5.636l4.95 4.95z\\"></path></svg></button></div></li><li class=\\"c-autocomplete--multi__selection-list-item w-max\\" style=\\"order: 2;\\"><div role=\\"group\\" aria-label=\\"Default two\\" class=\\"flex mr-1 mb-1 w-max\\"><button type=\\"button\\" class=\\"c-btn c-btn--secondary c-autocomplete--multi__selected p-1 cursor-text\\" aria-label=\\"Edit Default two\\">Default two</button><button type=\\"button\\" class=\\"c-btn c-btn--secondary c-autocomplete--multi__selected p-1\\" aria-label=\\"Remove Default two\\"><svg class=\\"crayons-icon\\" width=\\"24\\" height=\\"24\\" xmlns=\\"http://www.w3.org/2000/svg\\"><path d=\\"m12 10.586 4.95-4.95 1.414 1.414-4.95 4.95 4.95 4.95-1.414 1.414-4.95-4.95-4.95 4.95-1.414-1.414 4.95-4.95-4.95-4.95L7.05 5.636l4.95 4.95z\\"></path></svg></button></div></li><li class=\\"self-center\\" style=\\"order: 3;\\"><input autocomplete=\\"off\\" class=\\"c-autocomplete--multi__input\\" aria-autocomplete=\\"list\\" aria-labelledby=\\"multi-select-label selected-items-list\\" aria-describedby=\\"input-description\\" aria-disabled=\\"false\\" type=\\"text\\" placeholder=\\"Add another...\\"></li></ul></div></div>"`;
|
||||
|
|
@ -10,3 +10,4 @@ export * from '@crayons/Modal';
|
|||
export * from '@crayons/Spinner';
|
||||
export * from '@crayons/MobileDrawer';
|
||||
export * from '@crayons/MarkdownToolbar';
|
||||
export * from '@crayons/MultiSelectAutocomplete';
|
||||
|
|
|
|||
|
|
@ -1,9 +1,8 @@
|
|||
import { h, Component } from 'preact';
|
||||
import PropTypes from 'prop-types';
|
||||
import linkState from 'linkstate';
|
||||
import { Tags } from '../shared/components/tags';
|
||||
import { Tags, DEFAULT_TAG_FORMAT } from '../shared/components/tags';
|
||||
import { OrganizationPicker } from '../organization/OrganizationPicker';
|
||||
import { DEFAULT_TAG_FORMAT } from '../article-form/components/TagsField';
|
||||
import { Title } from './components/Title';
|
||||
import { BodyMarkdown } from './components/BodyMarkdown';
|
||||
import { Categories } from './components/Categories';
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@ const NAVIGATION_KEYS = [
|
|||
];
|
||||
|
||||
const LETTERS_NUMBERS = /[a-z0-9]/i;
|
||||
export const DEFAULT_TAG_FORMAT = '[0-9A-Za-z, ]+';
|
||||
|
||||
/* TODO: Remove all instances of this.props.listing
|
||||
and refactor this component to be more generic */
|
||||
|
|
|
|||
|
|
@ -1,67 +1,212 @@
|
|||
describe('Add tags to article', () => {
|
||||
const exampleTopTags = [
|
||||
{ name: 'tagone' },
|
||||
{
|
||||
name: 'tagone',
|
||||
bg_color_hex: '#528973',
|
||||
},
|
||||
{
|
||||
name: 'tagtwo',
|
||||
rules_html:
|
||||
'<p>Here are some rules <a href="//www.test.com">link here</a></p>\n',
|
||||
short_summary: 'tag two short summary',
|
||||
bg_color_hex: '#c74701',
|
||||
},
|
||||
];
|
||||
|
||||
const exampleSearchResult = {
|
||||
result: [
|
||||
{
|
||||
name: 'suggestion',
|
||||
short_summary: 'suggestion summary',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
cy.testSetup();
|
||||
cy.fixture('users/articleEditorV2User.json').as('user');
|
||||
|
||||
cy.get('@user').then((user) => {
|
||||
cy.intercept('/tags/suggest', exampleTopTags).as('topTagsRequest');
|
||||
cy.loginAndVisit(user, '/new');
|
||||
cy.wait('@topTagsRequest');
|
||||
});
|
||||
});
|
||||
|
||||
it('automatically suggests top tags when field is focused', () => {
|
||||
cy.intercept('/tags/suggest', exampleTopTags);
|
||||
cy.findByRole('textbox', { name: 'Post Tags' }).focus();
|
||||
|
||||
cy.findByRole('button', { name: 'tagone' }).should('exist');
|
||||
cy.findByRole('button', {
|
||||
name: 'tagtwo',
|
||||
}).should('exist');
|
||||
afterEach(() => {
|
||||
// Tags added to a draft are saved in local storage, which can cause tags to be pre-filled in subsequent tests
|
||||
// We move away from the /new page, and clear the storage to ensure tests are isolated from each other
|
||||
cy.visit('/404');
|
||||
cy.clearLocalStorage();
|
||||
});
|
||||
|
||||
it('automatically suggests top tags again after tag insertion', () => {
|
||||
cy.intercept('/tags/suggest', exampleTopTags);
|
||||
cy.findByRole('textbox', { name: 'Post Tags' }).clear().type('something,');
|
||||
it('automatically suggests top tags when field is focused', () => {
|
||||
cy.findByRole('textbox', { name: 'Add up to 4 tags' }).focus();
|
||||
|
||||
// Search is in progress, top tags which don't match shouldn't be shown
|
||||
cy.findByRole('button', { name: 'tagone' }).should('not.exist');
|
||||
cy.findByRole('button', {
|
||||
name: 'tagtwo',
|
||||
}).should('not.exist');
|
||||
|
||||
// Users initiating fresh search after comma
|
||||
cy.findByRole('textbox', { name: 'Post Tags' }).focus();
|
||||
cy.findByRole('button', { name: 'tagone' }).should('exist');
|
||||
cy.findByRole('button', {
|
||||
name: 'tagtwo',
|
||||
cy.findByRole('heading', { name: 'Top tags' }).should('exist');
|
||||
cy.findByRole('option', { name: '# tagone' }).should('exist');
|
||||
cy.findByRole('option', {
|
||||
name: '# tagtwo tag two short summary',
|
||||
}).should('exist');
|
||||
});
|
||||
|
||||
it("doesn't suggest top tags already added", () => {
|
||||
cy.intercept('/tags/suggest', exampleTopTags);
|
||||
cy.findByRole('textbox', { name: 'Post Tags' }).focus();
|
||||
cy.findByRole('button', { name: 'tagone' }).click();
|
||||
cy.findByRole('textbox', { name: 'Add up to 4 tags' }).as('input').focus();
|
||||
cy.findByRole('option', { name: '# tagone' }).click();
|
||||
|
||||
cy.findByRole('button', { name: 'tagone' }).should('not.exist');
|
||||
cy.findByRole('button', {
|
||||
name: 'tagtwo',
|
||||
// Check input has 'reset' and still has focus
|
||||
cy.get('@input').should('have.value', '').should('have.focus');
|
||||
|
||||
cy.findByRole('heading', { name: 'Top tags' }).should('exist');
|
||||
// Check only the unselected top tag is presented
|
||||
cy.findByRole('option', {
|
||||
name: '# tagtwo tag two short summary',
|
||||
}).should('exist');
|
||||
cy.findByRole('option', { name: '# tagone' }).should('not.exist');
|
||||
});
|
||||
|
||||
// Regression test for #14867
|
||||
it('keeps on suggesting a tag even if we typed in the whole name', () => {
|
||||
cy.findByRole('textbox', { name: 'Post Tags' }).clear().type('tag1');
|
||||
it('searches and shows suggestions', () => {
|
||||
cy.intercept('search/tags**', exampleSearchResult);
|
||||
|
||||
// Search is in progress, both tags should still be shown
|
||||
cy.findByRole('button', { name: 'tag1' }).should('exist');
|
||||
cy.findByRole('button', { name: 'tag2' }).should('not.exist');
|
||||
cy.findByRole('textbox', { name: 'Add up to 4 tags' }).type('a');
|
||||
|
||||
// Top tags should not be shown when a search starts
|
||||
cy.findByRole('heading', { name: 'Top tags' }).should('not.exist');
|
||||
cy.findByRole('option', { name: '# suggestion suggestion summary' }).should(
|
||||
'exist',
|
||||
);
|
||||
});
|
||||
|
||||
it('displays currently typed text as a suggestion if no suggestions found', () => {
|
||||
cy.intercept('search/tags**', { result: [] });
|
||||
|
||||
cy.findByRole('textbox', { name: 'Add up to 4 tags' }).type('a');
|
||||
|
||||
// Top tags should not be shown when a search starts
|
||||
cy.findByRole('heading', { name: 'Top tags' }).should('not.exist');
|
||||
cy.findByRole('option', { name: '# a' }).should('exist');
|
||||
});
|
||||
|
||||
it('selects a tag by clicking, typing a comma or space', () => {
|
||||
cy.findByRole('textbox', { name: 'Add up to 4 tags' }).as('input').focus();
|
||||
|
||||
cy.findByRole('option', { name: '# tagone' }).click();
|
||||
|
||||
cy.findByRole('button', { name: 'Edit tagone' }).should('exist');
|
||||
cy.findByRole('button', { name: 'Remove tagone' }).should('exist');
|
||||
|
||||
cy.get('@input').type('something,');
|
||||
|
||||
cy.findByRole('button', { name: 'Edit something' }).should('exist');
|
||||
cy.findByRole('button', { name: 'Remove something' }).should('exist');
|
||||
|
||||
cy.get('@input').type('another ');
|
||||
|
||||
cy.findByRole('button', { name: 'Edit another' }).should('exist');
|
||||
cy.findByRole('button', { name: 'Remove another' }).should('exist');
|
||||
});
|
||||
|
||||
it('selects currently entered text when input blurs', () => {
|
||||
cy.findByRole('textbox', { name: 'Add up to 4 tags' })
|
||||
.type('something')
|
||||
.blur();
|
||||
|
||||
cy.findByRole('button', { name: 'Edit something' }).should('exist');
|
||||
cy.findByRole('button', { name: 'Remove something' }).should('exist');
|
||||
});
|
||||
|
||||
it('edits and deletes a previous selection', () => {
|
||||
cy.findByRole('textbox', { name: 'Add up to 4 tags' })
|
||||
.as('input')
|
||||
.type('something,');
|
||||
|
||||
cy.findByRole('button', { name: 'Edit something' }).click();
|
||||
cy.get('@input').should('have.value', 'something').type('else,');
|
||||
cy.findByRole('button', { name: 'Edit somethingelse' }).should('exist');
|
||||
|
||||
cy.findByRole('button', { name: 'Remove somethingelse' }).click();
|
||||
|
||||
// Buttons should be removed and top tags should be showing again
|
||||
cy.findByRole('button', { name: 'Edit somethingelse' }).should('not.exist');
|
||||
cy.findByRole('button', { name: 'Remove somethingelse' }).should(
|
||||
'not.exist',
|
||||
);
|
||||
cy.findByRole('heading', { name: 'Top tags' }).should('exist');
|
||||
});
|
||||
|
||||
it('edits a previous tag when backspace typed', () => {
|
||||
cy.findByRole('textbox', { name: 'Add up to 4 tags' })
|
||||
.as('input')
|
||||
.type('something,');
|
||||
|
||||
// Verify tag is selected
|
||||
cy.findByRole('button', { name: 'Edit something' }).should('exist');
|
||||
|
||||
// Verify input was cleared on selection, then type a backspace and check we're now editing the tag again
|
||||
cy.get('@input')
|
||||
.should('have.focus')
|
||||
.should('have.value', '')
|
||||
.type('{backspace}')
|
||||
.should('have.value', 'something');
|
||||
|
||||
// When editing the edit/remove buttons should not be present any more
|
||||
cy.findByRole('button', { name: 'Edit something' }).should('not.exist');
|
||||
cy.findByRole('button', { name: 'Remove something' }).should('not.exist');
|
||||
});
|
||||
|
||||
it('splits an edited value if space or comma are typed', () => {
|
||||
cy.findByRole('textbox', { name: 'Add up to 4 tags' })
|
||||
.as('input')
|
||||
.type('onetwothree,');
|
||||
|
||||
cy.findByRole('button', { name: 'Edit onetwothree' }).click();
|
||||
|
||||
// Check the input is pre-filled with the selection, and move the cursor to before 'three' before entering a comma
|
||||
cy.get('@input')
|
||||
.should('have.value', 'onetwothree')
|
||||
.type('{leftarrow}{leftarrow}{leftarrow}{leftarrow}{leftarrow},');
|
||||
|
||||
// Everything before the comma should have been selected
|
||||
cy.findByRole('button', { name: 'Edit onetwo' }).should('exist');
|
||||
// And input should still contain everything to the right of the comma
|
||||
cy.get('@input').should('have.value', 'three');
|
||||
|
||||
// Repeat, this time using a space
|
||||
cy.findByRole('button', { name: 'Edit onetwo' }).click();
|
||||
cy.get('@input')
|
||||
.should('have.value', 'onetwo')
|
||||
.type('{leftarrow}{leftarrow}{leftarrow} ');
|
||||
|
||||
// Everything before the space should have been selected
|
||||
cy.findByRole('button', { name: 'Edit one' }).should('exist');
|
||||
cy.get('@input').should('have.value', 'two');
|
||||
});
|
||||
|
||||
it('shows a message and prevents further selections when the maximum tags (4) have been added', () => {
|
||||
cy.findByRole('textbox', { name: 'Add up to 4 tags' })
|
||||
.as('input')
|
||||
.type('one, two, three, four,');
|
||||
|
||||
cy.get('@input').should('have.focus').should('have.value', '');
|
||||
|
||||
// Top tags should not be shown when max has been reached
|
||||
cy.findByRole('heading', { name: 'Top tags' }).should('not.exist');
|
||||
cy.findByText('Only 4 selections allowed').should('exist');
|
||||
|
||||
// Disabled state should be communicated to screen reader users
|
||||
cy.get('@input').should('have.attr', 'aria-disabled', 'true');
|
||||
|
||||
// Try to select another tag by typing
|
||||
cy.get('@input').type('a');
|
||||
// Message should still exist
|
||||
cy.findByText('Only 4 selections allowed').should('exist');
|
||||
// No options should be shown
|
||||
cy.findAllByRole('option').should('have.length', 0);
|
||||
|
||||
// Try to select by typing a comma, and check nothing happens
|
||||
cy.get('@input').type(',').should('have.value', 'a');
|
||||
cy.findByRole('button', { name: 'Edit a' }).should('not.exist');
|
||||
|
||||
// Try to select by typing a space, and check nothing happens
|
||||
cy.get('@input').type(' ').should('have.value', 'a');
|
||||
cy.findByRole('button', { name: 'Edit a' }).should('not.exist');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -128,7 +128,7 @@ describe('Post Editor', () => {
|
|||
.as('postTitle')
|
||||
.type('This is some title that should be reverted');
|
||||
cy.get('@articleForm')
|
||||
.findByLabelText(/^Post Tags$/i)
|
||||
.findByLabelText('Add up to 4 tags')
|
||||
.as('postTags')
|
||||
.type('tag1, tag2, tag3');
|
||||
cy.get('@articleForm')
|
||||
|
|
@ -140,7 +140,12 @@ describe('Post Editor', () => {
|
|||
'have.value',
|
||||
'This is some title that should be reverted',
|
||||
);
|
||||
cy.get('@postTags').should('have.value', 'tag1, tag2, tag3');
|
||||
|
||||
// Check all tag selections are present
|
||||
cy.findByRole('button', { name: 'Remove tag1' }).should('exist');
|
||||
cy.findByRole('button', { name: 'Remove tag2' }).should('exist');
|
||||
cy.findByRole('button', { name: 'Remove tag3' }).should('exist');
|
||||
|
||||
getPostContent().should(
|
||||
'have.value',
|
||||
'This is some text that should be reverted',
|
||||
|
|
@ -155,9 +160,15 @@ describe('Post Editor', () => {
|
|||
.findByLabelText(/^Post Title$/i)
|
||||
.should('have.value', '');
|
||||
cy.get('@articleForm')
|
||||
.findByLabelText(/^Post Tags$/i)
|
||||
.findByLabelText('Add up to 4 tags')
|
||||
.as('postTags')
|
||||
.should('have.value', '');
|
||||
|
||||
// Check tag selections are not present
|
||||
cy.findByRole('button', { name: 'Remove tag1' }).should('not.exist');
|
||||
cy.findByRole('button', { name: 'Remove tag2' }).should('not.exist');
|
||||
cy.findByRole('button', { name: 'Remove tag3' }).should('not.exist');
|
||||
|
||||
getPostContent().should('have.value', '');
|
||||
});
|
||||
|
||||
|
|
@ -194,12 +205,22 @@ describe('Post Editor', () => {
|
|||
force: true,
|
||||
});
|
||||
|
||||
// Check original tag selections are present, and remove them
|
||||
cy.findByRole('button', { name: 'Remove beginner' })
|
||||
.click()
|
||||
.should('not.exist');
|
||||
cy.findByRole('button', { name: 'Remove ruby' })
|
||||
.click()
|
||||
.should('not.exist');
|
||||
cy.findByRole('button', { name: 'Remove go' })
|
||||
.click()
|
||||
.should('not.exist');
|
||||
|
||||
// Add the new tags
|
||||
cy.get('@articleForm')
|
||||
.findByLabelText(/^Post Tags$/i)
|
||||
.findByRole('textbox', { name: 'Add up to 4 tags' })
|
||||
.as('postTags')
|
||||
.should('have.value', 'beginner, ruby, go') // checking for original value first
|
||||
.clear()
|
||||
.type('tag1, tag2, tag3');
|
||||
.type('tag1, tag2, tag3,');
|
||||
|
||||
getPostContent()
|
||||
.should('have.value', `This is a Test Post's contents.`) // checking for original value first
|
||||
|
|
@ -213,7 +234,10 @@ describe('Post Editor', () => {
|
|||
'This is some title that should be reverted',
|
||||
);
|
||||
|
||||
cy.get('@postTags').should('have.value', 'tag1, tag2, tag3');
|
||||
// Check new tags are selected
|
||||
cy.findByRole('button', { name: 'Remove tag1' });
|
||||
cy.findByRole('button', { name: 'Remove tag2' });
|
||||
cy.findByRole('button', { name: 'Remove tag3' });
|
||||
|
||||
getPostContent().should(
|
||||
'have.value',
|
||||
|
|
@ -228,12 +252,13 @@ describe('Post Editor', () => {
|
|||
// are no longer the same reference after reverting changes in the editor.
|
||||
cy.get('@articleForm')
|
||||
.findByLabelText(/^Post Title$/i)
|
||||
.as('postTags')
|
||||
.should('have.value', 'Test Post');
|
||||
cy.get('@articleForm')
|
||||
.findByLabelText(/^Post Tags$/i)
|
||||
.as('postTags')
|
||||
.should('have.value', 'beginner, ruby, go');
|
||||
|
||||
// check the correct original tags are present
|
||||
cy.findByRole('button', { name: 'Remove beginner' });
|
||||
cy.findByRole('button', { name: 'Remove ruby' });
|
||||
cy.findByRole('button', { name: 'Remove go' });
|
||||
|
||||
getPostContent().should(
|
||||
'have.value',
|
||||
`This is a Test Post's contents.`,
|
||||
|
|
@ -244,7 +269,6 @@ describe('Post Editor', () => {
|
|||
it('should not revert changes in the editor if the member clicks cancel in the confirmation dialog', () => {
|
||||
cy.findByRole('form', { name: /^Edit post$/i }).as('articleForm');
|
||||
const title = 'Some Title';
|
||||
const tags = 'tag1, tag2, tag3';
|
||||
const content = 'This is some text in the body.';
|
||||
|
||||
// Fill out the title, tags, and content for an post.
|
||||
|
|
@ -253,16 +277,21 @@ describe('Post Editor', () => {
|
|||
.as('postTitle')
|
||||
.type(title);
|
||||
cy.get('@articleForm')
|
||||
.findByLabelText(/^Post Tags$/i)
|
||||
.findByRole('textbox', { name: 'Add up to 4 tags' })
|
||||
.as('postTags')
|
||||
.type(tags);
|
||||
.type('tag1, tag2, tag3,');
|
||||
cy.get('@articleForm')
|
||||
.findByLabelText(/^Post Content$/i)
|
||||
.as('postContent')
|
||||
.type(content, { force: true });
|
||||
|
||||
cy.get('@postTitle').should('have.value', title);
|
||||
cy.get('@postTags').should('have.value', tags);
|
||||
|
||||
// Check tags are added
|
||||
cy.findByRole('button', { name: 'Remove tag1' });
|
||||
cy.findByRole('button', { name: 'Remove tag2' });
|
||||
cy.findByRole('button', { name: 'Remove tag3' });
|
||||
|
||||
getPostContent().should('have.value', content);
|
||||
|
||||
cy.findByRole('button', { name: /^Revert new changes$/i }).click();
|
||||
|
|
@ -273,7 +302,12 @@ describe('Post Editor', () => {
|
|||
// NOTE: The aliases for the title and tags input are not being used because the DOM nodes
|
||||
// are no longer the same reference after reverting changes in the editor.
|
||||
cy.get('@postTitle').should('have.value', title);
|
||||
cy.get('@postTags').should('have.value', tags);
|
||||
|
||||
// Check tags are still present
|
||||
cy.findByRole('button', { name: 'Remove tag1' });
|
||||
cy.findByRole('button', { name: 'Remove tag2' });
|
||||
cy.findByRole('button', { name: 'Remove tag3' });
|
||||
|
||||
getPostContent().should('have.value', content);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue