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:
Suzanne Aitchison 2022-01-21 08:58:05 +00:00 committed by GitHub
parent 87ff8f16a1
commit 222ce06f0f
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
17 changed files with 1569 additions and 296 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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↵↵![Alt Text](/i/12qpyywb0jlj6hksp9fn.png)';
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');

View file

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

View file

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

View file

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

View file

@ -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',
};

View file

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

View file

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

View file

@ -10,3 +10,4 @@ export * from '@crayons/Modal';
export * from '@crayons/Spinner';
export * from '@crayons/MobileDrawer';
export * from '@crayons/MarkdownToolbar';
export * from '@crayons/MultiSelectAutocomplete';

View file

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

View file

@ -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 */

View file

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

View file

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