Refine tag component behavior
This commit is contained in:
parent
654a28c203
commit
46f561d41f
@ -2,8 +2,8 @@ import { convertStringToArray, parameterize } from '@/utility/string';
|
|||||||
import { clsx } from 'clsx/lite';
|
import { clsx } from 'clsx/lite';
|
||||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
|
||||||
const KEYDOWN_KEY = 'keydown';
|
const KEY_KEYDOWN = 'keydown';
|
||||||
const CREATE_LABEL = 'Create ';
|
const CREATE_LABEL = 'Create';
|
||||||
|
|
||||||
export default function TagInput({
|
export default function TagInput({
|
||||||
name,
|
name,
|
||||||
@ -24,7 +24,7 @@ export default function TagInput({
|
|||||||
const inputRef = useRef<HTMLInputElement>(null);
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
const optionsRef = useRef<HTMLInputElement>(null);
|
const optionsRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
const [hasFocus, setHasFocus] = useState(false);
|
const [shouldShowMenu, setShouldShowMenu] = useState(false);
|
||||||
const [inputText, setInputText] = useState('');
|
const [inputText, setInputText] = useState('');
|
||||||
const [selectedOptionIndex, setSelectedOptionIndex] = useState<number>();
|
const [selectedOptionIndex, setSelectedOptionIndex] = useState<number>();
|
||||||
|
|
||||||
@ -39,7 +39,7 @@ export default function TagInput({
|
|||||||
!selectedOptions.includes(inputTextFormatted);
|
!selectedOptions.includes(inputTextFormatted);
|
||||||
|
|
||||||
const optionsFiltered = (isInputTextUnique
|
const optionsFiltered = (isInputTextUnique
|
||||||
? [`${CREATE_LABEL}"${inputTextFormatted}"`]
|
? [`${CREATE_LABEL} "${inputTextFormatted}"`]
|
||||||
: []).concat(options
|
: []).concat(options
|
||||||
.filter(option =>
|
.filter(option =>
|
||||||
!selectedOptions.includes(option) &&
|
!selectedOptions.includes(option) &&
|
||||||
@ -48,12 +48,20 @@ export default function TagInput({
|
|||||||
option.includes(inputTextFormatted)
|
option.includes(inputTextFormatted)
|
||||||
)));
|
)));
|
||||||
|
|
||||||
|
const hideMenu = useCallback((shouldBlurInput?: boolean) => {
|
||||||
|
setShouldShowMenu(false);
|
||||||
|
setSelectedOptionIndex(undefined);
|
||||||
|
if (shouldBlurInput) {
|
||||||
|
inputRef.current?.blur();
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
const addOption = useCallback((option?: string) => {
|
const addOption = useCallback((option?: string) => {
|
||||||
if (option && !selectedOptions.includes(option)) {
|
if (option && !selectedOptions.includes(option)) {
|
||||||
onChange?.([
|
onChange?.([
|
||||||
...selectedOptions,
|
...selectedOptions,
|
||||||
option.startsWith(CREATE_LABEL)
|
option.startsWith(CREATE_LABEL)
|
||||||
? option.slice(CREATE_LABEL.length + 1, -1)
|
? option.slice(CREATE_LABEL.length, -1)
|
||||||
: option,
|
: option,
|
||||||
]
|
]
|
||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
@ -71,10 +79,12 @@ export default function TagInput({
|
|||||||
inputRef.current?.focus();
|
inputRef.current?.focus();
|
||||||
}, [onChange, selectedOptions]);
|
}, [onChange, selectedOptions]);
|
||||||
|
|
||||||
// Reset selected option index when focus is lost
|
// Show options when input text changes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!hasFocus) { setSelectedOptionIndex(undefined); }
|
if (inputText) {
|
||||||
}, [hasFocus]);
|
setShouldShowMenu(true);
|
||||||
|
}
|
||||||
|
}, [inputText]);
|
||||||
|
|
||||||
// Focus option in the DOM when selected index changes
|
// Focus option in the DOM when selected index changes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -88,6 +98,7 @@ export default function TagInput({
|
|||||||
// Setup keyboard listener
|
// Setup keyboard listener
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const ref = containerRef.current;
|
const ref = containerRef.current;
|
||||||
|
|
||||||
const listener = (e: KeyboardEvent) => {
|
const listener = (e: KeyboardEvent) => {
|
||||||
// Keys which always trap focus
|
// Keys which always trap focus
|
||||||
switch (e.key) {
|
switch (e.key) {
|
||||||
@ -137,20 +148,22 @@ export default function TagInput({
|
|||||||
case 'Backspace':
|
case 'Backspace':
|
||||||
if (inputText === '' && selectedOptions.length > 0) {
|
if (inputText === '' && selectedOptions.length > 0) {
|
||||||
removeOption(selectedOptions[selectedOptions.length - 1]);
|
removeOption(selectedOptions[selectedOptions.length - 1]);
|
||||||
|
hideMenu();
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
case 'Escape':
|
case 'Escape':
|
||||||
inputRef.current?.blur();
|
hideMenu(true);
|
||||||
setHasFocus(false);
|
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
ref?.addEventListener(KEYDOWN_KEY, listener);
|
|
||||||
return () => ref?.removeEventListener(KEYDOWN_KEY, listener);
|
ref?.addEventListener(KEY_KEYDOWN, listener);
|
||||||
|
|
||||||
|
return () => ref?.removeEventListener(KEY_KEYDOWN, listener);
|
||||||
}, [
|
}, [
|
||||||
inputText,
|
inputText,
|
||||||
removeOption,
|
removeOption,
|
||||||
hasFocus,
|
hideMenu,
|
||||||
selectedOptions,
|
selectedOptions,
|
||||||
selectedOptionIndex,
|
selectedOptionIndex,
|
||||||
optionsFiltered,
|
optionsFiltered,
|
||||||
@ -161,11 +174,10 @@ export default function TagInput({
|
|||||||
<div
|
<div
|
||||||
ref={containerRef}
|
ref={containerRef}
|
||||||
className="flex flex-col w-full"
|
className="flex flex-col w-full"
|
||||||
onFocus={() => setHasFocus(true)}
|
onFocus={() => setShouldShowMenu(true)}
|
||||||
onBlur={e => {
|
onBlur={e => {
|
||||||
if (!e.currentTarget.contains(e.relatedTarget)) {
|
if (!e.currentTarget.contains(e.relatedTarget)) {
|
||||||
setHasFocus(false);
|
hideMenu();
|
||||||
setSelectedOptionIndex(undefined);
|
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@ -213,7 +225,7 @@ export default function TagInput({
|
|||||||
<div
|
<div
|
||||||
ref={optionsRef}
|
ref={optionsRef}
|
||||||
className={clsx(
|
className={clsx(
|
||||||
!(hasFocus && optionsFiltered.length > 0) && 'hidden',
|
!(shouldShowMenu && optionsFiltered.length > 0) && 'hidden',
|
||||||
'control absolute top-0 mt-3 w-full z-10 !px-1.5 !py-1.5',
|
'control absolute top-0 mt-3 w-full z-10 !px-1.5 !py-1.5',
|
||||||
'max-h-[8rem] overflow-y-auto',
|
'max-h-[8rem] overflow-y-auto',
|
||||||
'flex flex-col gap-y-1',
|
'flex flex-col gap-y-1',
|
||||||
@ -238,6 +250,7 @@ export default function TagInput({
|
|||||||
addOption(option);
|
addOption(option);
|
||||||
setInputText('');
|
setInputText('');
|
||||||
}}
|
}}
|
||||||
|
onFocus={() => setSelectedOptionIndex(index)}
|
||||||
>
|
>
|
||||||
{option}
|
{option}
|
||||||
</div>)}
|
</div>)}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user