From abeff25ec0ba12ab21d576abbcb40f050c39e3e3 Mon Sep 17 00:00:00 2001 From: Ridhwana Date: Thu, 23 Jun 2022 15:41:45 +0200 Subject: [PATCH] BETA version 2 of the Storybook Multi Input (Editing and Accessibility) (#17894) * refactor: use the DefaultSelectionTemplate to list out the items * refactor: rename function so that we map it to the deselect item event * feat: first pass of adding edit functionality * feat: update the name of the function from clearSelection to clearInput * feat: setup the edit state by ensuring that we set the editValue and the inputPosition and then use useEffect to monitor changes * chore: remove the old method in favour of a shared component * feat: set the order of the items * refactor: rename handleBlur function name to handleInputBlur * feat: ensure that the edit field gets resized according to its current input * feat: oops add inputSizerRef * feat: when an item is being edited we need to ensure that it gets set back into the correct position and that we clear the previous state * feat: handle the actual resizing fo the field on an input edit * feat: use a shared DefaultSelectionTemplate for both the multiautocomplete and the multiinput component * feat: make the defaultSelectionTemplate customizable based on the variant adn the classname * feat: pressing backspace will start editing a previous selection if there is one * feat: read out the selected items as its removed or edited * feat: pass the regex through as a prop * feat: move the functions around * feat: add screen-reader only class * Update app/javascript/crayons/MultiInput/__stories__/MultiInput.stories.jsx Co-authored-by: Julianna Tetreault <32834804+juliannatetreault@users.noreply.github.com> * Update app/javascript/crayons/MultiInput/MultiInput.jsx Co-authored-by: Suzanne Aitchison * feat: add the props to JSDoc Co-authored-by: Julianna Tetreault <32834804+juliannatetreault@users.noreply.github.com> Co-authored-by: Suzanne Aitchison --- .../stylesheets/components/multi-input.scss | 29 +++ .../crayons/MultiInput/MultiInput.jsx | 235 ++++++++++++++---- .../__stories__/MultiInput.stories.jsx | 4 + .../DefaultSelectionTemplate.jsx | 24 -- .../MultiSelectAutocomplete.jsx | 3 +- .../components/defaultSelectionTemplate.jsx | 48 ++++ 6 files changed, 269 insertions(+), 74 deletions(-) delete mode 100644 app/javascript/crayons/MultiSelectAutocomplete/DefaultSelectionTemplate.jsx create mode 100644 app/javascript/shared/components/defaultSelectionTemplate.jsx diff --git a/app/assets/stylesheets/components/multi-input.scss b/app/assets/stylesheets/components/multi-input.scss index 5d56709ec..de8b5b79f 100644 --- a/app/assets/stylesheets/components/multi-input.scss +++ b/app/assets/stylesheets/components/multi-input.scss @@ -22,3 +22,32 @@ } } } + +.c-btn.c-input--multi__selected { + &:first-child { + border-top-right-radius: 0; + border-bottom-right-radius: 0; + border: solid var(--base-10); + border-right: none; + color: var(--base-90); + } + + &:last-child { + border-top-left-radius: 0; + border-bottom-left-radius: 0; + border: solid var(--base-10); + border-left: none; + } + + &:first-child:hover, + &:first-child:focus-visible { + background: none; + color: none; + } + + &:last-child:hover, + &:last-child:focus-visible { + background: none; + color: var(--accent-danger); + } +} diff --git a/app/javascript/crayons/MultiInput/MultiInput.jsx b/app/javascript/crayons/MultiInput/MultiInput.jsx index 342c54d9e..368b9f036 100644 --- a/app/javascript/crayons/MultiInput/MultiInput.jsx +++ b/app/javascript/crayons/MultiInput/MultiInput.jsx @@ -1,106 +1,237 @@ import { h, Fragment } from 'preact'; -import { useRef, useState } from 'preact/hooks'; +import PropTypes from 'prop-types'; +import { useRef, useState, useEffect } from 'preact/hooks'; +import { DefaultSelectionTemplate } from '../../shared/components/defaultSelectionTemplate'; const KEYS = { ENTER: 'Enter', COMMA: ',', SPACE: ' ', + DELETE: 'Backspace', }; -// TODO: think about how this may change based on -// a different usage. We will most likely want this to be passed in as a prop. -const ALLOWED_CHARS_REGEX = /([a-zA-Z0-9@.])/; /** * Component allowing users to add multiple entries for a given input field that get displayed as destructive pills * * @param {Object} props * @param {string} props.placeholder Input placeholder text + * @param {string} props.regex Optional regular expression used to restrict the input + * @param {Function} props.SelectionTemplate Optional Preact component to render selected items */ -export const MultiInput = ({ placeholder }) => { +export const MultiInput = ({ + placeholder, + regex, + SelectionTemplate = DefaultSelectionTemplate, +}) => { const inputRef = useRef(null); - const [items, setItems] = useState([]); + const inputSizerRef = useRef(null); + const selectedItemsRef = useRef(null); - const handleBlur = ({ target: { value } }) => { + const [items, setItems] = useState([]); + const [editValue, setEditValue] = useState(null); + const [inputPosition, setInputPosition] = useState(null); + + 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 (input && inputPosition !== null) { + // Entering 'edit' mode + resizeInputToContentSize(); + input.value = editValue; + const { length: cursorPosition } = editValue; + input.focus(); + // This will set the cursor position at the end of the text. + input.setSelectionRange(cursorPosition, cursorPosition); + } + }, [inputPosition, editValue]); + + const handleInputBlur = ({ target: { value } }) => { addItemToList(value); - clearSelection(); + clearInput(); + }; + + 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 handleKeyDown = (e) => { + const { value: currentValue } = inputRef.current; + switch (e.key) { case KEYS.SPACE: case KEYS.ENTER: case KEYS.COMMA: e.preventDefault(); addItemToList(e.target.value); - clearSelection(); + clearInput(); + break; + case KEYS.DELETE: + if (currentValue === '') { + e.preventDefault(); + editPreviousSelectionIfExists(); + } break; default: - if (!ALLOWED_CHARS_REGEX.test(e.key)) { + if (regex && !regex.test(e.key)) { e.preventDefault(); } } }; - const handleDestructiveClick = (clickedItem) => { - const newArr = items.filter((item) => item !== clickedItem); - setItems(newArr); - }; - const addItemToList = (value) => { // TODO: we will want to do some validation here based on a prop if (value.trim().length > 0) { - setItems([...items, value]); + // If an item was edited, we want to keep it in the same position in the list + const insertIndex = inputPosition !== null ? inputPosition : items.length; + const newSelections = [ + ...items.slice(0, insertIndex), + value, + ...items.slice(insertIndex), + ]; + + // We update the hidden selected items list, so additions are announced to screen reader users + const listItem = document.createElement('li'); + listItem.innerText = value; + selectedItemsRef.current.appendChild(listItem); + + setItems([...newSelections]); + exitEditState({}); } }; - const clearSelection = () => { + const clearInput = () => { inputRef.current.value = ''; }; - const listItems = items.map((item, index) => ( -
  • -
    - -
    -
  • - )); + const resizeInputToContentSize = () => { + const { current: input } = inputRef; + + if (input) { + input.style.width = `${inputSizerRef.current.clientWidth}px`; + } + }; + + const deselectItem = (clickedItem) => { + const newArr = items.filter((item) => item !== clickedItem); + setItems(newArr); + + // 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 === clickedItem) { + selectionNode.remove(); + } + }); + }; + + // If there is a previous selection, then pop it into edit mode + const editPreviousSelectionIfExists = () => { + if (items.length > 0 && inputPosition !== 0) { + const nextEditIndex = + inputPosition !== null ? inputPosition - 1 : items.length - 1; + + const item = items[nextEditIndex]; + deselectItem(item); + enterEditState(item, nextEditIndex); + } + }; + + const enterEditState = (editItem, editItemIndex) => { + inputSizerRef.current.innerText = editItem; + deselectItem(editItem); + setEditValue(editItem); + setInputPosition(editItemIndex); + }; + + const exitEditState = ({ nextInputValue = '' }) => { + // Reset 'edit mode' input resizing + inputRef.current?.style?.removeProperty('width'); + + inputSizerRef.current.innerText = nextInputValue; + setEditValue(nextInputValue); + setInputPosition(nextInputValue === '' ? null : inputPosition + 1); + // Blurring away while clearing the input + if (nextInputValue === '') { + inputRef.current.value = ''; + } + }; + + const allSelectedItemElements = items.map((item, index) => { + // When we are in "edit mode" we visually display the input between the other selections + // If the item being edited appears before the item being rendered then we set its position to + // the index + 1 which matches the order, however, any items that appear after the item that is + // being edited will need to increment their position by one to make place for the item being edited. + + // at this point the position is already set + const defaultPosition = index + 1; + const appearsBeforeInput = inputPosition === null || index < inputPosition; + const position = appearsBeforeInput ? defaultPosition : defaultPosition + 1; + return ( +
  • + enterEditState(item, index)} + onDeselect={() => deselectItem(item)} + /> +
  • + ); + }); return ( +