Switch to new tag component
This commit is contained in:
parent
e9b714e785
commit
f4913db81e
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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
210
src/components/TagInput.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user