Vercel/src/components/TagInput.tsx
Sam Becker dbf55badf6
Optimize Next.js 16 behavior (#349)
* Remove unused desktop redirect component

* Tweak useEffect/setState interactions

* Address more next.js 16 linting

* Tweak secret loading

* Finish linting setstate/useeffect interactions

* Disable ref lint warnings
2025-10-27 09:49:16 -05:00

484 lines
14 KiB
TypeScript

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<HTMLInputElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
const optionsRef = useRef<HTMLDivElement>(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, 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<AnnotatedTag[]>(() => 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 <>
<span className="truncate">
{option?.label ?? value}
</span>
{icon && <span className="text-medium shrink-0">
{icon}
</span>}
</>;
}, [options, defaultIcon]);
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 && 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();
}
}}
>
<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',
)}
>
{/* Selected Options */}
{selectedOptions
.filter(Boolean)
.map(option =>
<span
key={option}
role="button"
aria-label={`Remove tag "${option}"`}
className={clsx(
'inline-flex items-center gap-2 min-w-0',
'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-sm',
)}
onClick={() => removeOption(option)}
>
{defaultIconSelected}
{renderTag(labelForValueOverride?.(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);
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"
/>
<input type="hidden" name={name} value={value} />
{accessory}
</div>
<div className="relative">
{shouldShowMenu && optionsFiltered.length > 0 &&
<div
className={clsx(
'component-surface',
'absolute top-3 w-full px-1.5 py-1.5',
'max-h-[8rem] overflow-y-auto flex flex-col',
'shadow-lg dark:shadow-xl',
)}
>
<MaskedScroll
id={ARIA_ID_TAG_OPTIONS}
role="listbox"
className="flex flex-col gap-y-1 text-xl"
ref={optionsRef}
fadeSize={16}
>
{/* Menu Options */}
{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-2',
'px-1.5 py-1 rounded-sm',
'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 inline-flex items-center gap-2 min-w-0">
{renderTag(value)}
</span>
{annotation &&
<span
className="truncate text-dim text-sm"
aria-label={annotationAria}
>
<span aria-hidden={Boolean(annotationAria)}>
{annotation}
</span>
</span>}
</div>)}
</MaskedScroll>
</div>}
</div>
</div>
);
}