diff --git a/src/components/FieldSetWithStatus.tsx b/src/components/FieldSetWithStatus.tsx index cc5cf811..a680704c 100644 --- a/src/components/FieldSetWithStatus.tsx +++ b/src/components/FieldSetWithStatus.tsx @@ -6,7 +6,6 @@ import Spinner from './Spinner'; import { clsx } from 'clsx/lite'; import { FieldSetType } from '@/photo/form'; import TagInput from './TagInput'; -import { convertStringToArray } from '@/utility/string'; export default function FieldSetWithStatus({ id, @@ -77,6 +76,7 @@ export default function FieldSetWithStatus({ onChange={e => onChange?.(e.target.value)} className={clsx( 'w-full', + clsx(Boolean(error) && 'error'), // Use special class because `select` can't be readonly readOnly || pending && 'disabled-select', )} @@ -92,14 +92,12 @@ export default function FieldSetWithStatus({ )} : commaSeparatedOptions - ? - { - onChange?.(value.join(', ')); - console.log(value.join(', ')); - }} + onChange={onChange} + className={clsx(Boolean(error) && 'error')} readOnly={readOnly || pending} /> : } diff --git a/src/components/ImageBlurFallback.tsx b/src/components/ImageBlurFallback.tsx index d34c1fc1..1b1162e8 100644 --- a/src/components/ImageBlurFallback.tsx +++ b/src/components/ImageBlurFallback.tsx @@ -1,5 +1,5 @@ import { BLUR_ENABLED } from '@/site/config'; -import clsx from 'clsx/lite'; +import { clsx} from 'clsx/lite'; import Image, { ImageProps } from 'next/image'; export default function ImageBlurFallback(props: ImageProps) { diff --git a/src/components/MoreMenu.tsx b/src/components/MoreMenu.tsx index 903cf688..d553d059 100644 --- a/src/components/MoreMenu.tsx +++ b/src/components/MoreMenu.tsx @@ -1,4 +1,4 @@ -import clsx from 'clsx/lite'; +import { clsx} from 'clsx/lite'; import Link from 'next/link'; import { Menu } from '@headlessui/react'; import { FiMoreHorizontal } from 'react-icons/fi'; diff --git a/src/components/TagInput.tsx b/src/components/TagInput.tsx index 7b9c091f..c2291547 100644 --- a/src/components/TagInput.tsx +++ b/src/components/TagInput.tsx @@ -1,19 +1,23 @@ -import clsx from 'clsx'; -import { useCallback, useEffect, useRef, useState } from 'react'; +import { convertStringToArray, parameterize } from '@/utility/string'; +import { clsx } from 'clsx/lite'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; const KEYDOWN_KEY = 'keydown'; - const CREATE_LABEL = 'Create '; export default function TagInput({ + name, + value = '', options = [], - selectedOptions = [], onChange, + className, readOnly, }: { + name: string + value?: string options?: string[] - selectedOptions?: string[] - onChange?: (options: string[]) => void + onChange?: (value: string) => void + className?: string readOnly?: boolean }) { const containerRef = useRef(null); @@ -24,56 +28,64 @@ export default function TagInput({ const [inputText, setInputText] = useState(''); const [selectedOptionIndex, setSelectedOptionIndex] = useState(); - const inputTextFormatted = inputText.toLocaleLowerCase().trim(); - const isInputTextNew = + const selectedOptions = useMemo(() => + convertStringToArray(value) ?? [] + , [value]); + + const inputTextFormatted = parameterize(inputText); + const isInputTextUnique = inputTextFormatted && !selectedOptions.includes(inputTextFormatted); - let optionsFiltered = options + const optionsFiltered = (isInputTextUnique + ? [`${CREATE_LABEL}"${inputTextFormatted}"`] + : []).concat(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); + const addOption = useCallback((option?: string) => { + if (option && !selectedOptions.includes(option)) { + onChange?.([ + ...selectedOptions, + option.startsWith(CREATE_LABEL) + ? option.slice(CREATE_LABEL.length + 1, -1) + : option, + ] + .filter(Boolean) + .map(option => option.toLocaleLowerCase().trim()).join(',')); + setSelectedOptionIndex(undefined); + } }, [onChange, selectedOptions]); + const removeOption = useCallback((option: string) => { + onChange?.(selectedOptions.filter(o => o !== option).join(',')); + }, [onChange, selectedOptions]); + + // Reset selected option index when focus is lost useEffect(() => { if (!hasFocus) { setSelectedOptionIndex(undefined); } }, [hasFocus]); + // Focus option in the DOM when selected index changes useEffect(() => { if (selectedOptionIndex !== undefined) { - const ref = optionsRef.current; - const options = ref?.querySelectorAll('div'); + const options = optionsRef.current?.querySelectorAll('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 'Enter': + case ',': case 'ArrowDown': case 'ArrowUp': case 'Escape': @@ -82,10 +94,20 @@ export default function TagInput({ } switch (e.key) { case 'Enter': + // Only trap focus if there are options to select + // otherwise allow form to submit + if (optionsFiltered.length > 0) { + e.stopImmediatePropagation(); + e.preventDefault(); + } addOption(optionsFiltered[selectedOptionIndex ?? 0]); inputRef.current?.focus(); setInputText(''); break; + case ',': + addOption(inputText); + setInputText(''); + break; case 'ArrowDown': setSelectedOptionIndex(i => { if (i === undefined || i >= optionsFiltered.length - 1) { @@ -105,9 +127,8 @@ export default function TagInput({ }); break; case 'Backspace': - if (inputText === '') { - onChange?.(selectedOptions.slice(0, -1)); - // setHasFocus(false); + if (inputText === '' && selectedOptions.length > 0) { + removeOption(selectedOptions[selectedOptions.length - 1]); } break; case 'Escape': @@ -119,7 +140,7 @@ export default function TagInput({ return () => ref?.removeEventListener(KEYDOWN_KEY, listener); }, [ inputText, - onChange, + removeOption, hasFocus, selectedOptions, selectedOptionIndex, @@ -139,7 +160,12 @@ export default function TagInput({ } }} > -
+
{selectedOptions .filter(Boolean) .map(option => @@ -150,10 +176,10 @@ export default function TagInput({ 'whitespace-nowrap', 'px-1.5 py-0.5', 'bg-gray-100 dark:bg-gray-800', + 'active:bg-gray-50 dark:active:bg-gray-900', 'rounded-sm', )} - onClick={() => - onChange?.(selectedOptions.filter(o => o !== option))} + onClick={() => removeOption(option)} > {option} )} @@ -161,7 +187,7 @@ export default function TagInput({ ref={inputRef} type="text" className={clsx( - 'grow !min-w-0 !p-0', + 'grow !min-w-0 !p-0 text-lg', '!border-none !ring-transparent', )} value={inputText} @@ -170,29 +196,32 @@ export default function TagInput({ autoCapitalize="off" readOnly={readOnly} /> +
{hasFocus && optionsFiltered.length > 0 &&
{optionsFiltered.map((option, index) =>
{ addOption(option); inputRef.current?.focus(); diff --git a/src/photo/form/PhotoForm.tsx b/src/photo/form/PhotoForm.tsx index 9101f210..61b78a2d 100644 --- a/src/photo/form/PhotoForm.tsx +++ b/src/photo/form/PhotoForm.tsx @@ -198,9 +198,7 @@ export default function PhotoForm({ > Cancel - + {type === 'create' ? 'Create' : 'Update'}
diff --git a/src/photo/form/index.ts b/src/photo/form/index.ts index 33adf52e..d9c2a762 100644 --- a/src/photo/form/index.ts +++ b/src/photo/form/index.ts @@ -47,7 +47,6 @@ const FORM_METADATA: Record = { title: { label: 'title', capitalize: true }, tags: { label: 'tags', - note: 'comma-separated values', validate: tags => doesTagsStringIncludeFavs(tags) ? `'${TAG_FAVS}' is a reserved tag` : undefined, diff --git a/src/site/globals.css b/src/site/globals.css index 1b8325c6..bf2ff669 100644 --- a/src/site/globals.css +++ b/src/site/globals.css @@ -65,7 +65,7 @@ @apply rounded-md } - input.error, select.error { + .error { @apply border-red-500 dark:border-red-400 } diff --git a/src/tag/FavsTag.tsx b/src/tag/FavsTag.tsx index 3590f015..5496a276 100644 --- a/src/tag/FavsTag.tsx +++ b/src/tag/FavsTag.tsx @@ -2,7 +2,7 @@ import { FaStar } from 'react-icons/fa'; import EntityLink, { EntityLinkExternalProps } from '@/components/EntityLink'; import { TAG_FAVS } from '.'; import { pathForTag } from '@/site/paths'; -import clsx from 'clsx'; +import { clsx } from 'clsx/lite'; export default function FavsTag({ type,