import { AnnotatedTag } from '@/photo/form'; import { convertStringToArray, parameterize } from '@/utility/string'; import { clsx } from 'clsx/lite'; import { ReactNode, useCallback, useEffect, useMemo, useRef, useState, } from 'react'; import MaskedScroll from './MaskedScroll'; 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({ id, name, value = '', options = [], labelForValueOverride, defaultIcon, defaultIconSelected, accessory, onChange, onInputTextChange, showMenuOnDelete, className, readOnly, placeholder, limit, limitValidationMessage, allowNewValues = true, shouldParameterize, }: { id?: string name: string value?: string options?: AnnotatedTag[] labelForValueOverride?: (value: string) => string defaultIcon?: ReactNode defaultIconSelected?: ReactNode accessory?: ReactNode onChange?: (value: string) => void onInputTextChange?: (value: string) => void showMenuOnDelete?: boolean className?: string readOnly?: boolean placeholder?: string limit?: number limitValidationMessage?: string allowNewValues?: boolean shouldParameterize?: boolean }) { const behaveAsDropdown = limit === 1; 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, shouldParameterize) ?? [] , [value, shouldParameterize]); const hasReachedLimit = useMemo(() => limit !== undefined && selectedOptions.length >= limit && !behaveAsDropdown , [limit, behaveAsDropdown, selectedOptions]); const inputTextFormatted = shouldParameterize ? parameterize(inputText) : inputText.trim(); const isInputTextUnique = useMemo(() => { if (shouldParameterize) { // Check already-parameterized values return inputTextFormatted && !optionValues.includes(inputTextFormatted) && !selectedOptions.includes(inputTextFormatted); } else { // Parameterize for check only const inputTextParameterized = parameterize(inputTextFormatted); return inputTextFormatted && !optionValues .map(value => parameterize(value)) .includes((inputTextParameterized)) && !selectedOptions .map(value => parameterize(value)) .includes(inputTextParameterized); } }, [shouldParameterize, inputTextFormatted, optionValues, selectedOptions]); const optionsFiltered = useMemo(() => hasReachedLimit ? [{ value: limitValidationMessage ?? `Limit reached (${limit})` }] : (isInputTextUnique && allowNewValues ? [{ value: `${CREATE_LABEL} "${inputTextFormatted}"` }] : [] ).concat(options .filter(({ value, label }) =>{ // Make value and key searchable const key = `${value}-${label}`; return !selectedOptions.includes(key) && ( !inputTextFormatted || (shouldParameterize ? key.includes(inputTextFormatted) : (parameterize(key)).includes(parameterize(inputTextFormatted))) ); })) , [ hasReachedLimit, inputTextFormatted, isInputTextUnique, allowNewValues, limit, limitValidationMessage, options, selectedOptions, shouldParameterize, ]); const hideMenu = useCallback((shouldBlurInput?: boolean) => { setShouldShowMenu(false); setSelectedOptionIndex(undefined); if (shouldBlurInput) { inputRef.current?.blur(); } }, []); const addOptions = useCallback((options: (string | undefined)[]) => { const optionsToAdd = (options .filter(Boolean) as string[]) .map(option => option.startsWith(CREATE_LABEL) ? option.match(new RegExp(`^${CREATE_LABEL} "(.+)"$`))?.[1] ?? option : option) .map(option => shouldParameterize ? parameterize(option) : option) .filter(option => !selectedOptions.includes(option)); if (optionsToAdd.length > 0) { if (behaveAsDropdown) { // If behaving as dropdown, replace contents on add onChange?.(optionsToAdd[0]); } else { onChange?.([ ...selectedOptions, ...optionsToAdd, ].join(',')); } } setSelectedOptionIndex(undefined); setInputText(''); if ( behaveAsDropdown || (limit !== undefined && limit - 1 >= selectedOptions.length) ) { hideMenu(true); } else { inputRef.current?.focus(); } }, [ limit, behaveAsDropdown, selectedOptions, shouldParameterize, onChange, hideMenu, ]); const removeOption = useCallback((option: string) => { onChange?.(selectedOptions .filter(o => o !== (shouldParameterize ? parameterize(option) : option)) .join(',')); setSelectedOptionIndex(undefined); inputRef.current?.focus(); }, [shouldParameterize, onChange, selectedOptions]); // Show options when input text changes useEffect(() => { if (inputText) { if (inputText.includes(',')) { // eslint-disable-next-line react-hooks/set-state-in-effect addOptions(inputText.split(',')); } else { setShouldShowMenu(true); } } }, [inputText, addOptions, selectedOptions]); // 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 '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(); if (!hasReachedLimit) { addOptions([optionsFiltered[selectedOptionIndex ?? 0].value]); } } 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]); if (!showMenuOnDelete) { hideMenu(); } } break; case 'Escape': hideMenu(true); break; } }; ref?.addEventListener(KEY_KEYDOWN, listener); return () => ref?.removeEventListener(KEY_KEYDOWN, listener); }, [ inputText, removeOption, showMenuOnDelete, hideMenu, selectedOptions, selectedOptionIndex, optionsFiltered, addOptions, shouldShowMenu, hasReachedLimit, limit, ]); const renderTag = useCallback((value: string) => { const option = options.find(o => o.value === value); const icon = option?.icon ?? defaultIcon; return <> {option?.label ?? value} {icon && {icon} } ; }, [options, defaultIcon]); return (
setShouldShowMenu(true)} onBlur={e => { if (!e.currentTarget.contains(e.relatedTarget)) { // Capture text on blur if limit not yet reached if (inputText && !hasReachedLimit && allowNewValues) { addOptions([inputText]); } else if (allowNewValues) { // Only clear text when there's the possibility of // explicity adding arbitrary values, i.e., when it's not // used as autocomplete setInputText(''); } hideMenu(); } }} >
{selectedOptions.length === 0 ? 'No tags selected' : selectedOptions.join(', ') + ` tag${selectedOptions.length !== 1 ? 's' : ''} selected`}
{/* Selected Options */} {selectedOptions .filter(Boolean) .map(option => removeOption(option)} > {defaultIconSelected} {renderTag(labelForValueOverride?.(option) || option)} )} { setInputText(e.target.value); onInputTextChange?.(e.target.value); }} autoComplete="off" autoCapitalize="off" autoCorrect="off" readOnly={readOnly} placeholder={selectedOptions.length === 0 ? placeholder : undefined} onFocus={() => setSelectedOptionIndex(undefined)} onClick={() => { if (!shouldShowMenu) { setShouldShowMenu(true); } }} aria-autocomplete="list" aria-expanded={shouldShowMenu} aria-haspopup="true" aria-controls={shouldShowMenu ? ARIA_ID_TAG_OPTIONS : undefined} role="combobox" /> {accessory}
{shouldShowMenu && optionsFiltered.length > 0 &&
{/* Menu Options */} {optionsFiltered.map(({ value, annotation, annotationAria, }, index) =>
{ if (!hasReachedLimit) { addOptions([value]); } }} onFocus={() => setSelectedOptionIndex(index)} > {renderTag(value)} {annotation && {annotation} }
)}
}
); }