import { AnnotatedTag } from '@/photo/form'; import { convertStringToArray, parameterize } from '@/utility/string'; import { clsx } from 'clsx/lite'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; const KEY_KEYDOWN = 'keydown'; const CREATE_LABEL = 'Create'; const ARIA_ID_TAG_CONTROL = 'tag-control'; const ARIA_ID_TAG_OPTIONS = 'tag-options'; export default function TagInput({ name, value = '', options = [], onChange, className, readOnly, }: { name: string value?: string options?: AnnotatedTag[] onChange?: (value: string) => void className?: string readOnly?: boolean }) { const containerRef = useRef(null); const inputRef = useRef(null); const optionsRef = useRef(null); const [shouldShowMenu, setShouldShowMenu] = useState(false); const [inputText, setInputText] = useState(''); const [selectedOptionIndex, setSelectedOptionIndex] = useState(); const optionValues = useMemo(() => options.map(({ value }) => value) , [options]); const selectedOptions = useMemo(() => convertStringToArray(value) ?? [] , [value]); const inputTextFormatted = parameterize(inputText); const isInputTextUnique = inputTextFormatted && !optionValues.includes(inputTextFormatted) && !selectedOptions.includes(inputTextFormatted); const optionsFiltered = useMemo(() => (isInputTextUnique ? [{ value: `${CREATE_LABEL} "${inputTextFormatted}"` }] : [] ).concat(options .filter(({ value }) => !selectedOptions.includes(value) && ( !inputTextFormatted || value.includes(inputTextFormatted) ))) , [inputTextFormatted, isInputTextUnique, options, selectedOptions]); const hideMenu = useCallback((shouldBlurInput?: boolean) => { setShouldShowMenu(false); setSelectedOptionIndex(undefined); if (shouldBlurInput) { inputRef.current?.blur(); } }, []); const addOption = useCallback((option?: string) => { if (option && !selectedOptions.includes(option)) { onChange?.([ ...selectedOptions, option.startsWith(CREATE_LABEL) ? option.slice(CREATE_LABEL.length, -1) : option, ] .filter(Boolean) .map(parameterize) .join(',')); } setSelectedOptionIndex(undefined); inputRef.current?.focus(); }, [onChange, selectedOptions]); const removeOption = useCallback((option: string) => { onChange?.(selectedOptions.filter(o => o !== parameterize(option)).join(',')); setSelectedOptionIndex(undefined); inputRef.current?.focus(); }, [onChange, selectedOptions]); // Show options when input text changes useEffect(() => { if (inputText) { setShouldShowMenu(true); } }, [inputText]); // Focus option in the DOM when selected index changes useEffect(() => { if (selectedOptionIndex !== undefined) { const options = optionsRef.current?.querySelectorAll(':scope > div'); const option = options?.[selectedOptionIndex] as HTMLElement | undefined; option?.focus(); } }, [selectedOptionIndex]); // Setup keyboard listener useEffect(() => { const ref = containerRef.current; const listener = (e: KeyboardEvent) => { // Keys which always trap focus switch (e.key) { case ',': case 'ArrowDown': case 'ArrowUp': case 'Escape': e.stopImmediatePropagation(); e.preventDefault(); } switch (e.key) { case 'Enter': // Only trap focus if there are options to select // otherwise allow form to submit if (shouldShowMenu && optionsFiltered.length > 0) { e.stopImmediatePropagation(); e.preventDefault(); addOption(optionsFiltered[selectedOptionIndex ?? 0].value); setInputText(''); } break; case ',': addOption(inputText); setInputText(''); break; case 'ArrowDown': if (shouldShowMenu) { setSelectedOptionIndex(i => { if (i === undefined) { return optionsFiltered.length > 1 ? 1 : 0; } else if (i >= optionsFiltered.length - 1) { return 0; } else { return i + 1; } }); } else { setShouldShowMenu(true); } break; case 'ArrowUp': setSelectedOptionIndex(i => { if ( document.activeElement === inputRef.current && optionsFiltered.length > 0 ) { return optionsFiltered.length - 1; } else if (i === undefined || i === 0) { inputRef.current?.focus(); return undefined; } else { return i - 1; } }); break; case 'Backspace': if (inputText === '' && selectedOptions.length > 0) { removeOption(selectedOptions[selectedOptions.length - 1]); hideMenu(); } break; case 'Escape': hideMenu(true); break; } }; ref?.addEventListener(KEY_KEYDOWN, listener); return () => ref?.removeEventListener(KEY_KEYDOWN, listener); }, [ inputText, removeOption, hideMenu, selectedOptions, selectedOptionIndex, optionsFiltered, addOption, shouldShowMenu, ]); return (
setShouldShowMenu(true)} onBlur={e => { if (!e.currentTarget.contains(e.relatedTarget)) { hideMenu(); } }} >
{selectedOptions.length === 0 ? 'No tags selected' : selectedOptions.join(', ') + ` tag${selectedOptions.length !== 1 ? 's' : ''} selected`}
{selectedOptions .filter(Boolean) .map(option => removeOption(option)} > {option} )} setInputText(e.target.value)} autoComplete="off" autoCapitalize="off" readOnly={readOnly} onFocus={() => setSelectedOptionIndex(undefined)} aria-autocomplete="list" aria-expanded={shouldShowMenu} aria-haspopup="true" aria-controls={shouldShowMenu ? ARIA_ID_TAG_OPTIONS : undefined} role="combobox" />
{shouldShowMenu && optionsFiltered.length > 0 &&
{optionsFiltered.map(({ value, annotation, annotationAria, }, index) =>
{ addOption(value); setInputText(''); }} onFocus={() => setSelectedOptionIndex(index)} > {value} {annotation && {annotation} }
)}
}
); }