Refine tag component behavior

This commit is contained in:
Sam Becker 2024-02-05 12:00:47 -06:00
parent 654a28c203
commit 46f561d41f

View File

@ -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>)}