docbrown/app/javascript/crayons/MultiInput/MultiInput.jsx
Ridhwana abeff25ec0
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 <suzanne@forem.com>

* feat: add the props to JSDoc

Co-authored-by: Julianna Tetreault <32834804+juliannatetreault@users.noreply.github.com>
Co-authored-by: Suzanne Aitchison <suzanne@forem.com>
2022-06-23 15:41:45 +02:00

249 lines
7.8 KiB
JavaScript

import { h, Fragment } from 'preact';
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',
};
/**
* 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,
regex,
SelectionTemplate = DefaultSelectionTemplate,
}) => {
const inputRef = useRef(null);
const inputSizerRef = useRef(null);
const selectedItemsRef = useRef(null);
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);
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 <span> 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);
clearInput();
break;
case KEYS.DELETE:
if (currentValue === '') {
e.preventDefault();
editPreviousSelectionIfExists();
}
break;
default:
if (regex && !regex.test(e.key)) {
e.preventDefault();
}
}
};
const addItemToList = (value) => {
// TODO: we will want to do some validation here based on a prop
if (value.trim().length > 0) {
// 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 clearInput = () => {
inputRef.current.value = '';
};
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 (
<li
key={index}
className="c-input--multi__selection-list-item w-max"
style={{ order: position }}
>
<SelectionTemplate
name={item}
className="c-input--multi__selected"
onEdit={() => enterEditState(item, index)}
onDeselect={() => deselectItem(item)}
/>
</li>
);
});
return (
<Fragment>
<span
ref={inputSizerRef}
aria-hidden="true"
className="absolute pointer-events-none opacity-0 p-2"
/>
{/* A visually hidden list provides confirmation messages to screen reader users as an item is selected or removed */}
<div className="screen-reader-only">
<p>Selected items:</p>
<ul
ref={selectedItemsRef}
className="screen-reader-only list-none"
aria-live="assertive"
aria-atomic="false"
aria-relevant="additions removals"
/>
</div>
<div class="c-input--multi relative">
<div class="c-input--multi__wrapper-border crayons-textfield flex items-center cursor-text pb-9">
<ul class="list-none flex flex-wrap w-100">
{allSelectedItemElements}
<li
class="self-center"
style={{
order:
inputPosition === null ? items.length + 1 : inputPosition + 1,
}}
>
<input
autocomplete="off"
class="c-input--multi__input"
type="text"
onBlur={handleInputBlur}
onKeyDown={handleKeyDown}
placeholder={inputPosition === null ? placeholder : null}
onChange={handleInputChange}
ref={inputRef}
/>
</li>
</ul>
</div>
</div>
</Fragment>
);
};
MultiInput.propTypes = {
placeholder: PropTypes.string,
regex: PropTypes.string,
SelectionTemplate: PropTypes.func,
};