Refine TagInput behavior

This commit is contained in:
Sam Becker 2024-02-04 14:34:17 -06:00
parent 84481ea6cf
commit b77c186ae9
8 changed files with 84 additions and 60 deletions

View File

@ -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({
</option>)}
</select>
: commaSeparatedOptions
?
<TagInput
? <TagInput
name={id}
value={value}
options={commaSeparatedOptions}
selectedOptions={convertStringToArray(value)}
onChange={value => {
onChange?.(value.join(', '));
console.log(value.join(', '));
}}
onChange={onChange}
className={clsx(Boolean(error) && 'error')}
readOnly={readOnly || pending}
/>
: <input
@ -118,7 +116,7 @@ export default function FieldSetWithStatus({
readOnly={readOnly || pending}
className={clsx(
type === 'text' && 'w-full',
error && 'error',
Boolean(error) && 'error',
)}
/>}
</div>

View File

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

View File

@ -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';

View File

@ -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<HTMLInputElement>(null);
@ -24,56 +28,64 @@ export default function TagInput({
const [inputText, setInputText] = useState('');
const [selectedOptionIndex, setSelectedOptionIndex] = useState<number>();
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({
}
}}
>
<div className="w-full control !py-0 inline-flex items-center gap-2">
<div className={clsx(
className,
'w-full control !py-0 inline-flex items-center gap-2',
readOnly && 'cursor-not-allowed',
readOnly && 'bg-gray-100 dark:bg-gray-900 dark:text-gray-400',
)}>
{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}
</span>)}
@ -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}
/>
<input type="hidden" name={name} value={value} />
</div>
<div className="relative">
{hasFocus && optionsFiltered.length > 0 &&
<div
tabIndex={0}
ref={optionsRef}
className={clsx(
'control absolute top-0 mt-4 w-full z-10 !px-1.5 !py-1.5',
'text-xl',
'shadow-xl',
'flex flex-col gap-y-1',
'text-xl shadow-lg dark:shadow-xl',
)}
>
{optionsFiltered.map((option, index) =>
<div
key={option}
tabIndex={0}
className={clsx(
'cursor-pointer',
'px-1 py-1 rounded-sm',
index === 0 && selectedOptionIndex === undefined &&
'bg-gray-100 dark:bg-gray-800',
'hover:bg-gray-100 dark:hover:bg-gray-800',
'active:bg-gray-50 dark:active:bg-gray-900',
'focus:bg-gray-100 dark:focus:bg-gray-800',
'focus:border-none focus:ring-transparent',
'outline-gray-200 dark:outline-gray-700',
)}
tabIndex={index + 1}
onClick={() => {
addOption(option);
inputRef.current?.focus();

View File

@ -198,9 +198,7 @@ export default function PhotoForm({
>
Cancel
</Link>
<SubmitButtonWithStatus
disabled={!isFormValid(formData)}
>
<SubmitButtonWithStatus disabled={!isFormValid(formData)}>
{type === 'create' ? 'Create' : 'Update'}
</SubmitButtonWithStatus>
</div>

View File

@ -47,7 +47,6 @@ const FORM_METADATA: Record<keyof PhotoFormData, FormMeta> = {
title: { label: 'title', capitalize: true },
tags: {
label: 'tags',
note: 'comma-separated values',
validate: tags => doesTagsStringIncludeFavs(tags)
? `'${TAG_FAVS}' is a reserved tag`
: undefined,

View File

@ -65,7 +65,7 @@
@apply
rounded-md
}
input.error, select.error {
.error {
@apply
border-red-500 dark:border-red-400
}

View File

@ -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,