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>
This commit is contained in:
parent
765ac08d18
commit
abeff25ec0
6 changed files with 269 additions and 74 deletions
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 <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);
|
||||
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) => (
|
||||
<li
|
||||
key={index}
|
||||
class="c-input--multi__selection-list-item w-max"
|
||||
style="order: 1;"
|
||||
>
|
||||
<div role="group" aria-label="two" class="flex mr-1 mb-1 w-max">
|
||||
<button
|
||||
class="c-pill c-pill--action-icon c-pill--action-icon--destructive"
|
||||
type="button"
|
||||
>
|
||||
{item}
|
||||
<svg
|
||||
class="crayons-icon c-pill__action-icon"
|
||||
aria-hidden="true"
|
||||
focusable="false"
|
||||
width="18"
|
||||
height="18"
|
||||
viewBox="0 0 24 24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
onClick={() => handleDestructiveClick(item)}
|
||||
>
|
||||
<path d="m12 10.586 4.95-4.95 1.414 1.414-4.95 4.95 4.95 4.95-1.414 1.414-4.95-4.95-4.95 4.95-1.414-1.414 4.95-4.95-4.95-4.95L7.05 5.636l4.95 4.95z" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</li>
|
||||
));
|
||||
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">
|
||||
{listItems}
|
||||
<li class="self-center" style="order: 3;">
|
||||
{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={handleBlur}
|
||||
onBlur={handleInputBlur}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder={placeholder}
|
||||
placeholder={inputPosition === null ? placeholder : null}
|
||||
onChange={handleInputChange}
|
||||
ref={inputRef}
|
||||
/>
|
||||
</li>
|
||||
|
|
@ -110,3 +241,9 @@ export const MultiInput = ({ placeholder }) => {
|
|||
</Fragment>
|
||||
);
|
||||
};
|
||||
|
||||
MultiInput.propTypes = {
|
||||
placeholder: PropTypes.string,
|
||||
regex: PropTypes.string,
|
||||
SelectionTemplate: PropTypes.func,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -14,6 +14,9 @@ export default {
|
|||
description:
|
||||
'Placeholder text, shown when no selections have been made yet',
|
||||
},
|
||||
regex: {
|
||||
description: 'A regular expression used to validate the input',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
|
@ -24,6 +27,7 @@ export const Default = (args) => {
|
|||
|
||||
Default.args = {
|
||||
placeholder: 'Add an email address...',
|
||||
regex: /([a-zA-Z0-9@.])/,
|
||||
};
|
||||
|
||||
Default.story = {
|
||||
|
|
|
|||
|
|
@ -1,24 +0,0 @@
|
|||
import { h } from 'preact';
|
||||
import { Icon, ButtonNew as Button } from '@crayons';
|
||||
import { Close } from '@images/x.svg';
|
||||
|
||||
export const DefaultSelectionTemplate = ({ name, onEdit, onDeselect }) => (
|
||||
<div role="group" aria-label={name} className="flex mr-1 mb-1 w-max">
|
||||
<Button
|
||||
variant="secondary"
|
||||
className="c-autocomplete--multi__selected p-1 cursor-text"
|
||||
aria-label={`Edit ${name}`}
|
||||
onClick={onEdit}
|
||||
>
|
||||
{name}
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
className="c-autocomplete--multi__selected p-1"
|
||||
aria-label={`Remove ${name}`}
|
||||
onClick={onDeselect}
|
||||
>
|
||||
<Icon src={Close} />
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
|
|
@ -3,7 +3,7 @@
|
|||
import { h, Fragment } from 'preact';
|
||||
import PropTypes from 'prop-types';
|
||||
import { useEffect, useRef, useReducer } from 'preact/hooks';
|
||||
import { DefaultSelectionTemplate } from './DefaultSelectionTemplate';
|
||||
import { DefaultSelectionTemplate } from '../../shared/components/defaultSelectionTemplate';
|
||||
|
||||
const KEYS = {
|
||||
UP: 'ArrowUp',
|
||||
|
|
@ -545,6 +545,7 @@ export const MultiSelectAutocomplete = ({
|
|||
>
|
||||
<SelectionTemplate
|
||||
{...item}
|
||||
buttonVariant="secondary"
|
||||
onEdit={() => enterEditState(item, index)}
|
||||
onDeselect={() => deselectItem(item)}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,48 @@
|
|||
import { h } from 'preact';
|
||||
import PropTypes from 'prop-types';
|
||||
import { Icon, ButtonNew as Button } from '@crayons';
|
||||
import { Close } from '@images/x.svg';
|
||||
|
||||
/**
|
||||
* Responsible for the layout of a selected item in the crayons autocomplete and multi input components
|
||||
*
|
||||
* @param {string} name The selected item name
|
||||
* @param {string} buttonVariant Optional button variant
|
||||
* @param {string} className Optional classname for selected item
|
||||
* @param {Function} onEdit Callback for edit click on the name of the selected item
|
||||
* @param {Function} onDeselect Callback for deselect click on the close icon
|
||||
*/
|
||||
export const DefaultSelectionTemplate = ({
|
||||
name,
|
||||
buttonVariant = 'default',
|
||||
className = 'c-autocomplete--multi__selected',
|
||||
onEdit,
|
||||
onDeselect,
|
||||
}) => (
|
||||
<div role="group" aria-label={name} className="flex mr-1 mb-1 w-max">
|
||||
<Button
|
||||
variant={buttonVariant}
|
||||
className={`${className} p-1 cursor-text`}
|
||||
aria-label={`Edit ${name}`}
|
||||
onClick={onEdit}
|
||||
>
|
||||
{name}
|
||||
</Button>
|
||||
<Button
|
||||
variant={buttonVariant}
|
||||
className={`${className} p-1`}
|
||||
aria-label={`Remove ${name}`}
|
||||
onClick={onDeselect}
|
||||
>
|
||||
<Icon src={Close} />
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
|
||||
DefaultSelectionTemplate.propTypes = {
|
||||
name: PropTypes.string.isRequired,
|
||||
buttonVariant: PropTypes.string,
|
||||
className: PropTypes.string,
|
||||
onEdit: PropTypes.func.isRequired,
|
||||
onDeselect: PropTypes.func.isRequired,
|
||||
};
|
||||
Loading…
Add table
Reference in a new issue