Switch to new tag component

This commit is contained in:
Sam Becker 2024-02-03 23:49:08 -06:00
parent e9b714e785
commit f4913db81e
3 changed files with 219 additions and 103 deletions

View File

@ -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<React.HTMLProps<HTMLInputElement>, 'onChange'>) {
const items = (convertStringToArray(value) ?? [])
.map(tag => tag.trim())
.filter(Boolean);
const options = items
.filter(item => !optionsRaw.includes(item))
.concat(optionsRaw);
return (
<div className="relative">
<Combobox
value={items}
onChange={e => onChange?.(e.join(','))}
multiple
>
<div className="relative">
<Combobox.Input
className="w-full !pr-16"
onChange={e => onChange?.(e.target.value)}
displayValue={(tags: string[]) => tags.join(', ')}
{...{
id,
name,
type,
autoCapitalize,
readOnly,
}}
/>
{options &&
<Combobox.Button className={clsx(
'absolute top-0 right-0 border-none !bg-transparent',
'flex items-center',
)}>
<BiExpandVertical
className="text-gray-400"
size={16}
/>
</Combobox.Button>}
</div>
{options &&
<Combobox.Options className={clsx(
'control px-1.5 absolute mt-4 w-full',
'max-h-48 overflow-y-auto',
)}>
{options.map((tag) => (
<Combobox.Option
key={tag}
value={tag}
className={({ focus }) => clsx(
'p-1 rounded-[0.2rem] !hover:cursor',
focus && 'bg-gray-100 dark:bg-gray-900',
)}
>
{({ selected }) => <div className="flex items-center">
<span className="w-6">
{selected &&
<FaCheck size={12} className="translate-y-[1px]" />}
</span>
<span className="grow">
{tag}
</span>
</div>}
</Combobox.Option>
))}
</Combobox.Options>}
</Combobox>
</div>
);
}

View File

@ -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({
</option>)}
</select>
: commaSeparatedOptions
? <CommaSeparatedInput
id={id}
name={id}
value={value}
placeholder={placeholder}
onChange={onChange}
?
<TagInput
options={commaSeparatedOptions}
type={type}
autoCapitalize={!capitalize ? 'off' : undefined}
selectedOptions={convertStringToArray(value)}
onChange={value => {
onChange?.(value.join(', '));
console.log(value.join(', '));
}}
readOnly={readOnly || pending}
className={clsx(
type === 'text' && 'w-full',
error && 'error',
)}
/>
: <input
ref={inputRef}

210
src/components/TagInput.tsx Normal file
View File

@ -0,0 +1,210 @@
import clsx from 'clsx';
import { useCallback, useEffect, useRef, useState } from 'react';
const KEYDOWN_KEY = 'keydown';
const CREATE_LABEL = 'Create ';
export default function TagInput({
options = [],
selectedOptions = [],
onChange,
readOnly,
}: {
options?: string[]
selectedOptions?: string[]
onChange?: (options: string[]) => void
readOnly?: boolean
}) {
const containerRef = useRef<HTMLInputElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
const optionsRef = useRef<HTMLInputElement>(null);
const [hasFocus, setHasFocus] = useState(false);
const [inputText, setInputText] = useState('');
const [selectedOptionIndex, setSelectedOptionIndex] = useState<number>();
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 (
<div
ref={containerRef}
className="w-full"
onFocus={() => setHasFocus(true)}
onBlur={e => {
if (!e.currentTarget.contains(e.relatedTarget)) {
setHasFocus(false);
setSelectedOptionIndex(undefined);
}
}}
>
<div className="w-full control !py-0 inline-flex items-center gap-2">
{selectedOptions
.filter(Boolean)
.map(option =>
<span
key={option}
className={clsx(
'cursor-pointer',
'whitespace-nowrap',
'px-1.5 py-0.5',
'bg-gray-100 dark:bg-gray-800',
'rounded-sm',
)}
onClick={() =>
onChange?.(selectedOptions.filter(o => o !== option))}
>
{option}
</span>)}
<input
ref={inputRef}
type="text"
className={clsx(
'grow !min-w-0 !p-0',
'!border-none !ring-transparent',
)}
value={inputText}
onChange={e => setInputText(e.target.value)}
autoComplete="off"
autoCapitalize="off"
readOnly={readOnly}
/>
</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',
)}
>
{optionsFiltered.map((option, index) =>
<div
key={option}
className={clsx(
'cursor-pointer',
'px-1 py-1 rounded-sm',
'hover:bg-gray-100 dark:hover:bg-gray-800',
'focus:bg-gray-100 dark:focus:bg-gray-800',
'focus:border-none focus:ring-transparent',
)}
tabIndex={index + 1}
onClick={() => {
addOption(option);
inputRef.current?.focus();
setInputText('');
// setHasFocus(false);
}}
>
{option}
</div>)}
</div>}
</div>
</div>
);
}