* 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>
249 lines
7.8 KiB
JavaScript
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,
|
|
};
|