From f4913db81e0bac25b8813fa71a6965a3e6ead25b Mon Sep 17 00:00:00 2001 From: Sam Becker Date: Sat, 3 Feb 2024 23:49:08 -0600 Subject: [PATCH] Switch to new tag component --- src/components/CommaSeparatedInput.tsx | 90 ----------- src/components/FieldSetWithStatus.tsx | 22 ++- src/components/TagInput.tsx | 210 +++++++++++++++++++++++++ 3 files changed, 219 insertions(+), 103 deletions(-) delete mode 100644 src/components/CommaSeparatedInput.tsx create mode 100644 src/components/TagInput.tsx diff --git a/src/components/CommaSeparatedInput.tsx b/src/components/CommaSeparatedInput.tsx deleted file mode 100644 index bab7ee57..00000000 --- a/src/components/CommaSeparatedInput.tsx +++ /dev/null @@ -1,90 +0,0 @@ -import { convertStringToArray } from '@/utility/string'; -import { Combobox } from '@headlessui/react'; -import { clsx } from 'clsx/lite'; -import { BiExpandVertical } from 'react-icons/bi'; -import { FaCheck } from 'react-icons/fa'; - -export default function CommaSeparatedInput({ - onChange, - id, - name, - value, - type, - autoCapitalize, - readOnly, - options: optionsRaw = [], -}: { - value?: string - onChange?: (value: string) => void - options?: string[] -} & Omit, 'onChange'>) { - const items = (convertStringToArray(value) ?? []) - .map(tag => tag.trim()) - .filter(Boolean); - - const options = items - .filter(item => !optionsRaw.includes(item)) - .concat(optionsRaw); - - return ( -
- onChange?.(e.join(','))} - multiple - > -
- onChange?.(e.target.value)} - displayValue={(tags: string[]) => tags.join(', ')} - {...{ - id, - name, - type, - autoCapitalize, - readOnly, - }} - /> - {options && - - - } -
- {options && - - {options.map((tag) => ( - clsx( - 'p-1 rounded-[0.2rem] !hover:cursor', - focus && 'bg-gray-100 dark:bg-gray-900', - )} - > - {({ selected }) =>
- - {selected && - } - - - {tag} - - -
} -
- ))} -
} -
-
- ); -} diff --git a/src/components/FieldSetWithStatus.tsx b/src/components/FieldSetWithStatus.tsx index 71df72b3..cc5cf811 100644 --- a/src/components/FieldSetWithStatus.tsx +++ b/src/components/FieldSetWithStatus.tsx @@ -5,7 +5,8 @@ import { useFormStatus } from 'react-dom'; import Spinner from './Spinner'; import { clsx } from 'clsx/lite'; import { FieldSetType } from '@/photo/form'; -import CommaSeparatedInput from '@/components/CommaSeparatedInput'; +import TagInput from './TagInput'; +import { convertStringToArray } from '@/utility/string'; export default function FieldSetWithStatus({ id, @@ -91,20 +92,15 @@ export default function FieldSetWithStatus({ )} : commaSeparatedOptions - ? { + onChange?.(value.join(', ')); + console.log(value.join(', ')); + }} readOnly={readOnly || pending} - className={clsx( - type === 'text' && 'w-full', - error && 'error', - )} /> : void + readOnly?: boolean +}) { + const containerRef = useRef(null); + const inputRef = useRef(null); + const optionsRef = useRef(null); + + const [hasFocus, setHasFocus] = useState(false); + const [inputText, setInputText] = useState(''); + const [selectedOptionIndex, setSelectedOptionIndex] = useState(); + + const inputTextFormatted = inputText.toLocaleLowerCase().trim(); + const isInputTextNew = + inputTextFormatted && + !selectedOptions.includes(inputTextFormatted); + + let optionsFiltered = options + .filter(option => + !selectedOptions.includes(option) && + ( + !inputTextFormatted || + option.includes(inputTextFormatted) + )); + + if (isInputTextNew) { + optionsFiltered = [ + `${CREATE_LABEL}"${inputTextFormatted}"`, + ...optionsFiltered, + ]; + } + + const addOption = useCallback((option: string) => { + onChange?.([ + ...selectedOptions, + option.startsWith(CREATE_LABEL) + ? option.slice(CREATE_LABEL.length + 1, -1) + : option, + ] + .filter(Boolean) + .map(option => option.toLocaleLowerCase().trim())); + setSelectedOptionIndex(undefined); + }, [onChange, selectedOptions]); + + useEffect(() => { + if (!hasFocus) { setSelectedOptionIndex(undefined); } + }, [hasFocus]); + + useEffect(() => { + if (selectedOptionIndex !== undefined) { + const ref = optionsRef.current; + const options = ref?.querySelectorAll('div'); + const option = options?.[selectedOptionIndex] as HTMLElement | undefined; + console.log({options, option: option?.innerHTML}); + option?.focus(); + } + }, [selectedOptionIndex]); + + useEffect(() => { + const ref = containerRef.current; + const listener = (e: KeyboardEvent) => { + switch (e.key) { + case 'Enter': + case 'ArrowDown': + case 'ArrowUp': + case 'Escape': + e.stopImmediatePropagation(); + e.preventDefault(); + } + switch (e.key) { + case 'Enter': + addOption(optionsFiltered[selectedOptionIndex ?? 0]); + inputRef.current?.focus(); + setInputText(''); + break; + case 'ArrowDown': + setSelectedOptionIndex(i => { + if (i === undefined || i >= optionsFiltered.length - 1) { + return 0; + } else { + return i + 1; + } + }); + break; + case 'ArrowUp': + setSelectedOptionIndex(i => { + if (i === undefined || i === 0) { + return optionsFiltered.length - 1; + } else { + return i - 1; + } + }); + break; + case 'Backspace': + if (inputText === '') { + onChange?.(selectedOptions.slice(0, -1)); + // setHasFocus(false); + } + break; + case 'Escape': + setHasFocus(false); + break; + } + }; + ref?.addEventListener(KEYDOWN_KEY, listener); + return () => ref?.removeEventListener(KEYDOWN_KEY, listener); + }, [ + inputText, + onChange, + hasFocus, + selectedOptions, + selectedOptionIndex, + optionsFiltered, + addOption, + ]); + + return ( +
setHasFocus(true)} + onBlur={e => { + if (!e.currentTarget.contains(e.relatedTarget)) { + setHasFocus(false); + setSelectedOptionIndex(undefined); + } + }} + > +
+ {selectedOptions + .filter(Boolean) + .map(option => + + onChange?.(selectedOptions.filter(o => o !== option))} + > + {option} + )} + setInputText(e.target.value)} + autoComplete="off" + autoCapitalize="off" + readOnly={readOnly} + /> +
+
+ {hasFocus && optionsFiltered.length > 0 && +
+ {optionsFiltered.map((option, index) => +
{ + addOption(option); + inputRef.current?.focus(); + setInputText(''); + // setHasFocus(false); + }} + > + {option} +
)} +
} +
+
+ ); +}