nej-react-utils/Form/Core/AutocompleteSelect.tsx
2026-07-02 13:50:58 +02:00

144 lines
5.5 KiB
TypeScript

import { Combobox } from "@headlessui/react";
import { DropDownItem } from "@shared/nej-react-components/Parts/DropDown";
import { useEffect, useRef, useState } from "react";
import tw from "twin.macro";
import "styled-components/macro";
import { useField } from "@shared/nej-react-components/Parts/Input";
import { FieldHookConfig } from "formik";
type Option<T> = string | { value: T, label: string };
type RenderOption<T> = (item: Option<T>, state: { active: boolean, selected: boolean }) => JSX.Element;
export function AutocompleteSelect<T>({ title, titleProps = null, data, nullable = false, renderOption = undefined, onScrollEnd = undefined, onQueryChange = undefined, ...props }:
FieldHookConfig<T> &
({ title: string, titleProps?: any, data: Option<T>[] | ((query: string) => Promise<Option<T>[]>), nullable?: boolean, renderOption?: RenderOption<T>, onScrollEnd?: () => void, onQueryChange?: (query: string) => void } |
{ title: string, titleProps?: any, data: Record<string, string>, nullable?: boolean, renderOption?: RenderOption<string>, onScrollEnd?: () => void, onQueryChange?: (query: string) => void })
) {
const [field, meta, helpers] = useField<T>(props);
const [query, setQuery] = useState(field.value as string ?? "");
const [internalData, setInternalData] = useState<Option<T>[]>([]);
const sentinelRef = useRef(null);
useEffect(() => {
if (!onScrollEnd || !sentinelRef.current) return;
const observer = new IntersectionObserver(
(entries) => { if (entries[0].isIntersecting) onScrollEnd(); },
{ threshold: 0.1 }
);
observer.observe(sentinelRef.current);
return () => observer.disconnect();
}, [onScrollEnd]);
useEffect(() => {
(async () => {
if (typeof data === "function") {
//if data is async function await it
setInternalData(await data(query));
} else if (Array.isArray(data)) {
// When renderOption is provided the caller manages filtering externally, just show data as-is
if (renderOption) {
setInternalData(data);
} else {
setInternalData(
query === ""
? data
: data.filter((item) => {
if (typeof item === "string")
return item.toLowerCase().includes(query.toLowerCase());
return item.label.toLowerCase().includes(query.toLowerCase());
})
);
}
} else if (typeof data === "object") {
// TypeScript enum objects — convert values to { value, label } then filter
const enumItems = Object.values(data).map((value) => ({ value, label: value }));
setInternalData(
!renderOption && query !== ""
? enumItems.filter((item) => String(item.label).toLowerCase().includes(query.toLowerCase()))
: enumItems as any
);
}
else {
console.error("data is not an array or function :c");
setInternalData([]);
}
})();
}, [query, data]);
return <>
<Combobox<Option<T>>
{...props as any}
{...field}
value={
field.value
? internalData?.filter((item) =>
typeof item === "string"
? item == field.value
: item.value === field.value
)[0]
: null
}
onChange={(value) => {
// If value is an object (from internalData lookup), store only the primitive .value
if (Array.isArray(value)) {
const stored = value.map((v) => (typeof v === "string" ? v : v.value));
field.onChange({ target: { value: stored, name: field.name } });
} else {
const stored = value != null && typeof value === "object" ? (value as any).value : value;
field.onChange({ target: { value: stored, name: field.name } });
}
}
}
nullable={nullable as any}
>
<div css={[title && tw`my-1`, tw`relative`]} >
{title && (
<label
{...titleProps}
css={
[
tw`block text-secondary text-sm font-bold mb-2`,
titleProps?.css,
]}
>
{title}
</label>
)}
<div tw="relative" >
<Combobox.Input<Option<T>>
tw="bg-primary cursor-pointer appearance-none border-2 border-secondary rounded w-full py-2 px-4 text-primary leading-tight focus:outline-none focus:bg-secondary focus:border-accent transition duration-150 "
onChange={(event) => { setQuery(event.target.value); onQueryChange?.(event.target.value); }}
displayValue={(val) => (typeof val === "string" ? val : val?.label)}
/>
< Combobox.Button tw="absolute top-0 right-0 left-0 bottom-0 " />
</div>
< Combobox.Options tw="absolute z-10 max-h-60 w-full bg-trinary rounded-xl overflow-y-auto overflow-x-hidden" >
{internalData?.map((val) => (
<Combobox.Option
tw=""
key={typeof val === "string" ? val : val.label}
value={typeof val === "string" ? val : val.value}
>
{
renderOption ? (state) => renderOption(val as any, state)
: <DropDownItem>{typeof val === "string" ? val : val.label} </DropDownItem>
}
</Combobox.Option>
))}
{onScrollEnd && <div ref={sentinelRef} />}
</Combobox.Options>
</div>
</Combobox>
{
meta?.touched && meta.error ? (
<div tw="text-[#c23c3c]" > {meta.error} </div>
) : null
}
</>;
}