Vercel/src/components/TagInput.tsx
2025-03-22 17:48:19 -05:00

386 lines
11 KiB
TypeScript

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({
id,
name,
value = '',
options = [],
onChange,
showMenuOnDelete,
className,
readOnly,
placeholder,
limit,
limitValidationMessage,
}: {
id?: string
name: string
value?: string
options?: AnnotatedTag[]
onChange?: (value: string) => void
showMenuOnDelete?: boolean
className?: string
readOnly?: boolean
placeholder?: string
limit?: number
limitValidationMessage?: string
}) {
const containerRef = useRef<HTMLInputElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
const optionsRef = useRef<HTMLInputElement>(null);
const [shouldShowMenu, setShouldShowMenu] = useState(false);
const [inputText, setInputText] = useState('');
const [selectedOptionIndex, setSelectedOptionIndex] = useState<number>();
const optionValues = useMemo(() =>
options.map(({ value }) => value)
, [options]);
const selectedOptions = useMemo(() =>
convertStringToArray(value) ?? []
, [value]);
const hasReachedLimit = useMemo(() =>
limit !== undefined && selectedOptions.length >= limit
, [limit, selectedOptions]);
const inputTextFormatted = parameterize(inputText);
const isInputTextUnique =
inputTextFormatted &&
!optionValues.includes(inputTextFormatted) &&
!selectedOptions.includes(inputTextFormatted);
const optionsFiltered = useMemo<AnnotatedTag[]>(() => hasReachedLimit
? [{ value: limitValidationMessage ?? `Tag limit reached (${limit})` }]
: (isInputTextUnique
? [{ value: `${CREATE_LABEL} "${inputTextFormatted}"` }]
: []
).concat(options
.filter(({ value }) =>
!selectedOptions.includes(value) &&
(
!inputTextFormatted ||
value.includes(inputTextFormatted)
)))
, [
hasReachedLimit,
inputTextFormatted,
isInputTextUnique,
limit,
limitValidationMessage,
options,
selectedOptions,
]);
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 => parameterize(option))
.filter(option => !selectedOptions.includes(option));
if (optionsToAdd.length > 0) {
onChange?.([
...selectedOptions,
...optionsToAdd,
].join(','));
}
setSelectedOptionIndex(undefined);
setInputText('');
if (limit !== undefined && limit - 1 >= selectedOptions.length) {
hideMenu(true);
} else {
inputRef.current?.focus();
}
}, [limit, selectedOptions, onChange, hideMenu]);
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) {
if (inputText.includes(',')) {
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,
]);
return (
<div
ref={containerRef}
className="flex flex-col w-full group"
onFocus={() => setShouldShowMenu(true)}
onBlur={e => {
if (!e.currentTarget.contains(e.relatedTarget)) {
// Capture text on blur if limit not yet reached
if (inputText && !hasReachedLimit) {
addOptions([inputText]);
} else {
setInputText('');
}
hideMenu();
}
}}
>
<div
id={ARIA_ID_TAG_CONTROL}
role="region"
aria-live="polite"
className="sr-only mb-3 text-dim"
>
{selectedOptions.length === 0
? 'No tags selected'
: selectedOptions.join(', ') +
` tag${selectedOptions.length !== 1 ? 's' : ''} selected`}
</div>
<div
aria-controls={ARIA_ID_TAG_CONTROL}
className={clsx(
className,
'w-full control px-2! py-2!',
'-outline-offset-2 outline-blue-600',
'group-focus-within:outline-2 ',
'inline-flex flex-wrap 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 =>
<span
key={option}
role="button"
aria-label={`Remove tag "${option}"`}
className={clsx(
'text-main',
'cursor-pointer select-none',
'whitespace-nowrap',
'px-1.5 py-0.5',
'bg-gray-200/60 dark:bg-gray-800',
'active:bg-gray-200 dark:active:bg-gray-900',
'rounded-xs',
)}
onClick={() => removeOption(option)}
>
{option}
</span>)}
<input
id={id}
ref={inputRef}
type="text"
className={clsx(
'grow min-w-0! p-0! -my-2',
'outline-hidden border-none',
'placeholder:text-dim placeholder:text-[14px]',
'placeholder:translate-x-[2px]',
'placeholder:translate-y-[-1.5px]',
)}
size={10}
value={inputText}
onChange={e => setInputText(e.target.value)}
autoComplete="off"
autoCapitalize="off"
readOnly={readOnly}
placeholder={selectedOptions.length === 0 ? placeholder : undefined}
onFocus={() => setSelectedOptionIndex(undefined)}
aria-autocomplete="list"
aria-expanded={shouldShowMenu}
aria-haspopup="true"
aria-controls={shouldShowMenu ? ARIA_ID_TAG_OPTIONS : undefined}
role="combobox"
/>
<input type="hidden" name={name} value={value} />
</div>
{shouldShowMenu && optionsFiltered.length > 0 &&
<div className="relative">
<div
id={ARIA_ID_TAG_OPTIONS}
role="listbox"
ref={optionsRef}
className={clsx(
'control absolute top-0 mt-3 w-full z-10 px-1.5! py-1.5!',
'max-h-[8rem] overflow-y-auto',
'flex flex-col gap-y-1',
'text-xl shadow-lg dark:shadow-xl',
)}
>
{optionsFiltered.map(({
value,
annotation,
annotationAria,
}, index) =>
<div
key={value}
role="option"
aria-selected={
index === selectedOptionIndex ||
(index === 0 && selectedOptionIndex === undefined)
}
tabIndex={0}
className={clsx(
'group flex items-center gap-1',
'px-1.5 py-1 rounded-xs',
'text-base select-none',
hasReachedLimit ? 'cursor-not-allowed' : 'cursor-pointer',
'hover:bg-gray-100 dark:hover:bg-gray-800',
!hasReachedLimit &&
'active:bg-gray-50 dark:active:bg-gray-900',
'focus:bg-gray-100 dark:focus:bg-gray-800',
index === 0 && selectedOptionIndex === undefined &&
'bg-gray-100 dark:bg-gray-800',
'outline-hidden',
)}
onClick={() => {
if (!hasReachedLimit) {
addOptions([value]);
}
}}
onFocus={() => setSelectedOptionIndex(index)}
>
<span className="grow min-w-0 truncate">
{value}
</span>
{annotation &&
<span
className="whitespace-nowrap text-dim text-sm"
aria-label={annotationAria}
>
<span aria-hidden={Boolean(annotationAria)}>
{annotation}
</span>
</span>}
</div>)}
</div>
</div>}
</div>
);
}