diff --git a/app/assets/stylesheets/components/autocomplete.scss b/app/assets/stylesheets/components/autocomplete.scss
index b82f16954..b4792bfb3 100644
--- a/app/assets/stylesheets/components/autocomplete.scss
+++ b/app/assets/stylesheets/components/autocomplete.scss
@@ -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);
diff --git a/app/assets/stylesheets/views/article-form.scss b/app/assets/stylesheets/views/article-form.scss
index f291e615e..6c25fa08c 100644
--- a/app/assets/stylesheets/views/article-form.scss
+++ b/app/assets/stylesheets/views/article-form.scss
@@ -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...
diff --git a/app/javascript/article-form/components/TagAutocompleteOption.jsx b/app/javascript/article-form/components/TagAutocompleteOption.jsx
index 18a979fa8..1518672c3 100644
--- a/app/javascript/article-form/components/TagAutocompleteOption.jsx
+++ b/app/javascript/article-form/components/TagAutocompleteOption.jsx
@@ -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 (
{
+ const baseColorStyles = bgColorHex
+ ? {
+ '--tag-bg': `${bgColorHex}1a`,
+ '--tag-bg-hover': `${bgColorHex}1a`,
+ '--tag-prefix': bgColorHex,
+ }
+ : {};
+ return (
+
+
+
+
+ );
+};
+
+TagAutocompleteSelection.propTypes = {
+ name: PropTypes.string.isRequired,
+ bg_color_hex: PropTypes.string,
+ onEdit: PropTypes.func.isRequired,
+ onDeselect: PropTypes.func.isRequired,
+};
diff --git a/app/javascript/article-form/components/TagsField.jsx b/app/javascript/article-form/components/TagsField.jsx
index 40ca702d7..9de038775 100644
--- a/app/javascript/article-form/components/TagsField.jsx
+++ b/app/javascript/article-form/components/TagsField.jsx
@@ -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 (
-
-
-
+
Top tags
+ }
+ 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';
diff --git a/app/javascript/article-form/components/__tests__/Form.test.jsx b/app/javascript/article-form/components/__tests__/Form.test.jsx
index e2eda6689..5b436439a 100644
--- a/app/javascript/article-form/components/__tests__/Form.test.jsx
+++ b/app/javascript/article-form/components/__tests__/Form.test.jsx
@@ -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('', () => {
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('', () => {
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');
diff --git a/app/javascript/crayons/MultiSelectAutocomplete/DefaultSelectionTemplate.jsx b/app/javascript/crayons/MultiSelectAutocomplete/DefaultSelectionTemplate.jsx
new file mode 100644
index 000000000..b4b7cabc6
--- /dev/null
+++ b/app/javascript/crayons/MultiSelectAutocomplete/DefaultSelectionTemplate.jsx
@@ -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 }) => (
+
+
+
+
+);
diff --git a/app/javascript/crayons/MultiSelectAutocomplete/MultiSelectAutocomplete.jsx b/app/javascript/crayons/MultiSelectAutocomplete/MultiSelectAutocomplete.jsx
index 11b962112..3eec244d3 100644
--- a/app/javascript/crayons/MultiSelectAutocomplete/MultiSelectAutocomplete.jsx
+++ b/app/javascript/crayons/MultiSelectAutocomplete/MultiSelectAutocomplete.jsx
@@ -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 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) => (
-
-
-
-
-
-
- ));
+ 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 (
+
+ enterEditState(item, index)}
+ onDeselect={() => deselectItem(item)}
+ />
+
+ );
+ });
- const input = (
-
-
-
- );
+ const selectionsPlaceholder =
+ selectedItems.length > 0 ? PLACEHOLDER_SELECTIONS_MADE : placeholder;
+
+ const inputPlaceholder = allowSelections ? selectionsPlaceholder : null;
return (
@@ -326,7 +538,15 @@ export const MultiSelectAutocomplete = ({ labelText, fetchSuggestions }) => {
aria-hidden="true"
className="absolute pointer-events-none opacity-0 p-2"
/>
-
+
+
+ {maxSelections ? `Maximum ${maxSelections} selections` : ''}
+
{/* A visually hidden list provides confirmation messages to screen reader users as an item is selected or removed */}
@@ -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()}
>
- {suggestions.length > 0 ? (
-
- {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
- - selectItem({ selectedItem: suggestion })}
- onMouseDown={() =>
- dispatch({ type: 'setIgnoreBlue', payload: true })
- }
- >
- {suggestion}
-
- ))}
-
+ {showMaxSelectionsReached ? (
+
+ Only {maxSelections} selections allowed
+
+ ) : null}
+ {suggestions.length > 0 && allowSelections ? (
+
+ {inputRef.current?.value === '' ? staticSuggestionsHeading : null}
+
+ {suggestions.map((suggestion, index) => {
+ const { name: suggestionDisplayName } = suggestion;
+ return (
+ - {
+ selectItem({ selectedItem: suggestion });
+ dispatch({ type: 'setIgnoreBlur', payload: true });
+ }}
+ >
+ {SuggestionTemplate ? (
+
+ ) : (
+ suggestionDisplayName
+ )}
+
+ );
+ })}
+
+
) : null}
);
};
+
+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,
+};
diff --git a/app/javascript/crayons/MultiSelectAutocomplete/__stories__/MultiSelectAutocomplete.mdx b/app/javascript/crayons/MultiSelectAutocomplete/__stories__/MultiSelectAutocomplete.mdx
new file mode 100644
index 000000000..03fd1db1c
--- /dev/null
+++ b/app/javascript/crayons/MultiSelectAutocomplete/__stories__/MultiSelectAutocomplete.mdx
@@ -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.
diff --git a/app/javascript/crayons/MultiSelectAutocomplete/__stories__/MultiSelectAutocomplete.stories.jsx b/app/javascript/crayons/MultiSelectAutocomplete/__stories__/MultiSelectAutocomplete.stories.jsx
index aa30ec158..569483e15 100644
--- a/app/javascript/crayons/MultiSelectAutocomplete/__stories__/MultiSelectAutocomplete.stories.jsx
+++ b/app/javascript/crayons/MultiSelectAutocomplete/__stories__/MultiSelectAutocomplete.stories.jsx
@@ -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 (
);
};
+Default.args = {
+ border: true,
+ labelText: 'Example multi select autocomplete',
+ showLabel: true,
+ placeholder: 'Add a number...',
+ maxSelections: 4,
+ staticSuggestionsHeading: 'Static suggestions',
+};
+
Default.story = {
name: 'default',
};
diff --git a/app/javascript/crayons/MultiSelectAutocomplete/__tests__/MultiSelectAutocomplete.test.jsx b/app/javascript/crayons/MultiSelectAutocomplete/__tests__/MultiSelectAutocomplete.test.jsx
new file mode 100644
index 000000000..15a7e10ff
--- /dev/null
+++ b/app/javascript/crayons/MultiSelectAutocomplete/__tests__/MultiSelectAutocomplete.test.jsx
@@ -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('', () => {
+ it('renders default UI', () => {
+ const { container } = render(
+ {}}
+ />,
+ );
+ expect(container.innerHTML).toMatchSnapshot();
+ });
+
+ it('renders with customisation', () => {
+ const { container } = render(
+ {}}
+ />,
+ );
+ expect(container.innerHTML).toMatchSnapshot();
+ });
+
+ it('renders with default values', () => {
+ const { container } = render(
+ {}}
+ />,
+ );
+ expect(container.innerHTML).toMatchSnapshot();
+ });
+
+ it('shows static suggestions on focus, if provided', async () => {
+ const { getByLabelText, getByText, getByRole } = render(
+ {}}
+ />,
+ );
+
+ 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(
+ ,
+ );
+
+ 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 }) => custom suggestion: {name};
+ const mockFetchSuggestions = jest.fn(async () => [
+ { name: 'option1' },
+ { name: 'option2' },
+ ]);
+
+ const { getByLabelText, getByRole } = render(
+ ,
+ );
+
+ 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(
+ ,
+ );
+
+ 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(
+ ,
+ );
+
+ 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(
+ ,
+ );
+
+ 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(
+ ,
+ );
+
+ 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(
+ ,
+ );
+
+ 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(
+ []}
+ />,
+ );
+
+ // 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 }) => ;
+
+ const mockFetchSuggestions = jest.fn(async () => [
+ { name: 'option1' },
+ { name: 'option2' },
+ ]);
+
+ const { getByLabelText, getByRole } = render(
+ ,
+ );
+
+ 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(
+ []}
+ 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 }) => (
+
+ );
+
+ const { getByLabelText, getByRole, queryByRole } = render(
+ []}
+ 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(
+ []}
+ 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 }) => (
+
+ );
+
+ const { getByLabelText, getByRole, queryByRole } = render(
+ []}
+ 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(
+ ,
+ );
+
+ 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(
+ ,
+ );
+
+ 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(
+ []}
+ />,
+ );
+
+ // 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(
+ []}
+ />,
+ );
+
+ 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(
+ ,
+ );
+
+ 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');
+ });
+});
diff --git a/app/javascript/crayons/MultiSelectAutocomplete/__tests__/__snapshots__/MultiSelectAutocomplete.test.jsx.snap b/app/javascript/crayons/MultiSelectAutocomplete/__tests__/__snapshots__/MultiSelectAutocomplete.test.jsx.snap
new file mode 100644
index 000000000..61e1d361b
--- /dev/null
+++ b/app/javascript/crayons/MultiSelectAutocomplete/__tests__/__snapshots__/MultiSelectAutocomplete.test.jsx.snap
@@ -0,0 +1,7 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[` renders default UI 1`] = `""`;
+
+exports[` renders with customisation 1`] = `""`;
+
+exports[` renders with default values 1`] = `""`;
diff --git a/app/javascript/crayons/index.js b/app/javascript/crayons/index.js
index 0d5b646a0..dea219418 100644
--- a/app/javascript/crayons/index.js
+++ b/app/javascript/crayons/index.js
@@ -10,3 +10,4 @@ export * from '@crayons/Modal';
export * from '@crayons/Spinner';
export * from '@crayons/MobileDrawer';
export * from '@crayons/MarkdownToolbar';
+export * from '@crayons/MultiSelectAutocomplete';
diff --git a/app/javascript/listings/listingForm.jsx b/app/javascript/listings/listingForm.jsx
index 305a25c1d..876b65658 100644
--- a/app/javascript/listings/listingForm.jsx
+++ b/app/javascript/listings/listingForm.jsx
@@ -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';
diff --git a/app/javascript/shared/components/tags.jsx b/app/javascript/shared/components/tags.jsx
index 81911121f..5038dbe3b 100644
--- a/app/javascript/shared/components/tags.jsx
+++ b/app/javascript/shared/components/tags.jsx
@@ -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 */
diff --git a/cypress/integration/seededFlows/publishingFlows/addTagsToArticle.spec.js b/cypress/integration/seededFlows/publishingFlows/addTagsToArticle.spec.js
index b513f3d1e..ad56d4826 100644
--- a/cypress/integration/seededFlows/publishingFlows/addTagsToArticle.spec.js
+++ b/cypress/integration/seededFlows/publishingFlows/addTagsToArticle.spec.js
@@ -1,67 +1,212 @@
describe('Add tags to article', () => {
const exampleTopTags = [
- { name: 'tagone' },
+ {
+ name: 'tagone',
+ bg_color_hex: '#528973',
+ },
{
name: 'tagtwo',
- rules_html:
- 'Here are some rules link here
\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');
});
});
diff --git a/cypress/integration/seededFlows/publishingFlows/postBrowserPersistence.spec.js b/cypress/integration/seededFlows/publishingFlows/postBrowserPersistence.spec.js
index 3a5bf0144..7f834146e 100644
--- a/cypress/integration/seededFlows/publishingFlows/postBrowserPersistence.spec.js
+++ b/cypress/integration/seededFlows/publishingFlows/postBrowserPersistence.spec.js
@@ -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);
});
});