From 528bd2baa608292f3e603c7a7587832a46cc791a Mon Sep 17 00:00:00 2001 From: Suzanne Aitchison Date: Wed, 5 Jan 2022 15:01:07 +0000 Subject: [PATCH] Tag autocomplete: storybook multi-select autocomplete component (#15796) * core functionality in place * fix dark theme background issues * separate list for aria-live, add delete and blur functionality * fix issue with input resize on edit * handle input blur, prevent special characters, tweak keyup to keydown to ensure runs before change event * group buttons and add default styles * style tweaks * fix logic error with insert index * refactors * clear suggestions on blur, even if no input value * tweaks --- .../stylesheets/components/autocomplete.scss | 55 +++ .../MultiSelectAutocomplete.jsx | 390 ++++++++++++++++++ .../MultiSelectAutocomplete.stories.jsx | 42 ++ .../crayons/MultiSelectAutocomplete/index.js | 1 + 4 files changed, 488 insertions(+) create mode 100644 app/javascript/crayons/MultiSelectAutocomplete/MultiSelectAutocomplete.jsx create mode 100644 app/javascript/crayons/MultiSelectAutocomplete/__stories__/MultiSelectAutocomplete.stories.jsx create mode 100644 app/javascript/crayons/MultiSelectAutocomplete/index.js diff --git a/app/assets/stylesheets/components/autocomplete.scss b/app/assets/stylesheets/components/autocomplete.scss index 0c358637a..b82f16954 100644 --- a/app/assets/stylesheets/components/autocomplete.scss +++ b/app/assets/stylesheets/components/autocomplete.scss @@ -1,4 +1,5 @@ @import '../config/import'; +@import './forms'; @import '@reach/combobox/styles'; .crayons-autocomplete { @@ -57,3 +58,57 @@ font-size: var(--fs-s); } } + +.c-autocomplete--multi { + &__wrapper:focus-within { + @extend %form-styling-focus; + + .c-autocomplete--multi__input { + background-color: var(--form-bg-focus); + } + } + + &__input { + background-color: var(--form-bg); + color: var(--body-color); + border: none; + } + + .crayons-btn.c-autocomplete--multi__selected { + &:first-child { + border-top-right-radius: 0; + border-bottom-right-radius: 0; + } + + &:last-child { + border-top-left-radius: 0; + border-bottom-left-radius: 0; + } + + &:last-child:hover, + &:last-child:focus-visible { + color: var(--accent-danger); + } + } + + ul.c-autocomplete--multi__popover { + padding: var(--su-1); + z-index: var(--z-elevate); + background: var(--card-bg); + color: var(--card-color); + border-radius: var(--radius); + 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: none; + + [role='option'] { + cursor: pointer; + + &:hover, + &[aria-selected='true'] { + background: var(--link-bg-hover); + color: var(--link-color-hover); + } + } + } +} diff --git a/app/javascript/crayons/MultiSelectAutocomplete/MultiSelectAutocomplete.jsx b/app/javascript/crayons/MultiSelectAutocomplete/MultiSelectAutocomplete.jsx new file mode 100644 index 000000000..11b962112 --- /dev/null +++ b/app/javascript/crayons/MultiSelectAutocomplete/MultiSelectAutocomplete.jsx @@ -0,0 +1,390 @@ +/* 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 { useEffect, useRef, useReducer } from 'preact/hooks'; +import { Icon, Button } from '@crayons'; +import { Close } from '@images/x.svg'; + +const KEYS = { + UP: 'ArrowUp', + DOWN: 'ArrowDown', + ENTER: 'Enter', + ESCAPE: 'Escape', + DELETE: 'Backspace', + COMMA: ',', + SPACE: ' ', +}; + +const ALLOWED_CHARS_REGEX = /([a-zA-Z0-9])/; + +const reducer = (state, action) => { + switch (action.type) { + case 'setSelectedItems': + return { + ...state, + selectedItems: action.payload, + suggestions: [], + activeDescendentIndex: null, + }; + case 'setSuggestions': + return { + ...state, + suggestions: action.payload, + activeDescendentIndex: null, + }; + case 'updateEditState': + return { + ...state, + editValue: action.payload.editValue, + inputPosition: action.payload.inputPosition, + }; + case 'setActiveDescendentIndex': + return { ...state, activeDescendentIndex: action.payload }; + case 'setIgnoreBlur': + return { ...state, ignoreBlur: action.payload }; + default: + return state; + } +}; + +export const MultiSelectAutocomplete = ({ labelText, fetchSuggestions }) => { + const [state, dispatch] = useReducer(reducer, { + suggestions: [], + selectedItems: [], + inputPosition: null, + editValue: '', + activeDescendentIndex: null, + ignoreBlur: false, + }); + + const { + selectedItems, + suggestions, + inputPosition, + editValue, + activeDescendentIndex, + ignoreBlur, + } = state; + + const inputRef = useRef(null); + const inputSizerRef = useRef(null); + const selectedItemsRef = useRef(null); + + 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 : ''; + // 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 { + dispatch({ type: 'setSuggestions', payload: [] }); + } + + dispatch({ type: 'setIgnoreBlur', payload: false }); + }; + + useEffect(() => { + const { current: input } = inputRef; + if (inputPosition !== null) { + 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(); + } + }, [inputPosition, editValue]); + + const enterEditState = (editItem, editItemIndex) => { + inputSizerRef.current.innerText = editItem; + deselectItem(editItem); + + dispatch({ + type: 'updateEditState', + payload: { editValue: editItem, inputPosition: editItemIndex }, + }); + }; + + const exitEditState = (nextInputValue = '') => { + inputSizerRef.current.innerText = nextInputValue; + dispatch({ + type: 'updateEditState', + payload: { + editValue: nextInputValue, + inputPosition: nextInputValue === '' ? null : inputPosition + 1, + }, + }); + }; + + const resizeInputToContentSize = () => { + inputRef.current.style.width = `${inputSizerRef.current.clientWidth}px`; + }; + + 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(); + } + + const results = await fetchSuggestions(value); + dispatch({ + type: 'setSuggestions', + payload: results.filter((item) => !selectedItems.includes(item)), + }); + }; + + const clearInput = () => { + inputRef.current.value = ''; + dispatch({ type: 'setSuggestions', payload: [] }); + }; + + const handleKeyDown = (e) => { + const { selectionStart, value: currentValue } = inputRef.current; + + switch (e.key) { + case KEYS.DOWN: + e.preventDefault(); + + if ( + activeDescendentIndex !== null && + activeDescendentIndex < suggestions.length - 1 + ) { + dispatch({ + type: 'setActiveDescendentIndex', + payload: activeDescendentIndex + 1, + }); + } else { + dispatch({ type: 'setActiveDescendentIndex', payload: 0 }); + } + break; + case KEYS.UP: + e.preventDefault(); + + dispatch({ + type: 'setActiveDescendentIndex', + payload: + activeDescendentIndex >= 1 + ? activeDescendentIndex - 1 + : suggestions.length - 1, + }); + + break; + case KEYS.ENTER: + e.preventDefault(); + if (activeDescendentIndex !== null) { + selectItem({ selectedItem: suggestions[activeDescendentIndex] }); + } + break; + case KEYS.ESCAPE: + e.preventDefault(); + // Clear the input and suggestions + clearInput(); + break; + case KEYS.COMMA: + case KEYS.SPACE: + 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), + nextInputValue: currentValue.slice(selectionStart), + }); + } + break; + case KEYS.DELETE: + if (currentValue === '') { + e.preventDefault(); + editPreviousSelectionIfExists(); + } + break; + default: + if (!ALLOWED_CHARS_REGEX.test(e.key)) { + e.preventDefault(); + } + } + }; + + // If there is a previous selection, then pop it into edit mode + const editPreviousSelectionIfExists = () => { + if (selectedItems.length > 0 && inputPosition !== 0) { + const nextEditIndex = + inputPosition !== null ? inputPosition - 1 : selectedItems.length - 1; + + const item = selectedItems[nextEditIndex]; + deselectItem(item); + enterEditState(item, nextEditIndex); + } + }; + + const selectItem = ({ + selectedItem, + nextInputValue = '', + focusInput = true, + }) => { + // If a user has manually typed an item already selected, reset + if (selectedItems.includes(selectedItem)) { + clearInput(); + return; + } + + // If an item was edited, we want to keep it in the same position in the list + const insertIndex = + inputPosition !== null ? inputPosition : selectedItems.length; + const newSelections = [ + ...selectedItems.slice(0, insertIndex), + selectedItem, + ...selectedItems.slice(insertIndex), + ]; + + // We update the hidden selected items list, so additions are announced to screen reader users + const listItem = document.createElement('li'); + listItem.innerText = selectedItem; + selectedItemsRef.current.appendChild(listItem); + + exitEditState(nextInputValue); + dispatch({ type: 'setSelectedItems', payload: newSelections }); + + // Clear the text input + const { current: input } = inputRef; + input.value = nextInputValue; + focusInput && input.focus(); + }; + + const deselectItem = (deselectedItem) => { + dispatch({ + type: 'setSelectedItems', + payload: selectedItems.filter((item) => item !== deselectedItem), + }); + + // 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) { + selectionNode.remove(); + } + }); + }; + + const allSelectedItemElements = selectedItems.map((item, index) => ( +
  • +
    + + +
    +
  • + )); + + // When a user edits a tag, we need to move the input inside the selected items + const splitSelectionsAt = + inputPosition !== null ? inputPosition : selectedItems.length; + + const input = ( +
  • + +
  • + ); + + return ( + + + ); +}; diff --git a/app/javascript/crayons/MultiSelectAutocomplete/__stories__/MultiSelectAutocomplete.stories.jsx b/app/javascript/crayons/MultiSelectAutocomplete/__stories__/MultiSelectAutocomplete.stories.jsx new file mode 100644 index 000000000..aa30ec158 --- /dev/null +++ b/app/javascript/crayons/MultiSelectAutocomplete/__stories__/MultiSelectAutocomplete.stories.jsx @@ -0,0 +1,42 @@ +import { h } from 'preact'; +import { MultiSelectAutocomplete } from '../MultiSelectAutocomplete'; + +export default { + title: 'BETA/MultiSelectAutocomplete', +}; + +export const Default = () => { + const options = [ + 'one', + 'two', + 'three', + 'four', + 'five', + 'six', + 'seven', + 'eight', + 'nine', + 'ten', + ]; + + const fetchSuggestions = async (searchTerm) => { + const filteredSuggestions = options.filter((option) => + option.startsWith(searchTerm), + ); + + return filteredSuggestions.length === 0 + ? [searchTerm] + : filteredSuggestions; + }; + + return ( + + ); +}; + +Default.story = { + name: 'default', +}; diff --git a/app/javascript/crayons/MultiSelectAutocomplete/index.js b/app/javascript/crayons/MultiSelectAutocomplete/index.js new file mode 100644 index 000000000..c6c45ec75 --- /dev/null +++ b/app/javascript/crayons/MultiSelectAutocomplete/index.js @@ -0,0 +1 @@ +export * from './MultiSelectAutocomplete';