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 = string | { value: T, label: string }; type RenderOption = (item: Option, state: { active: boolean, selected: boolean }) => JSX.Element; export function AutocompleteSelect({ title, titleProps = null, data, nullable = false, renderOption = undefined, onScrollEnd = undefined, onQueryChange = undefined, ...props }: FieldHookConfig & ({ title: string, titleProps?: any, data: Option[] | ((query: string) => Promise[]>), nullable?: boolean, renderOption?: RenderOption, onScrollEnd?: () => void, onQueryChange?: (query: string) => void } | { title: string, titleProps?: any, data: Record, nullable?: boolean, renderOption?: RenderOption, onScrollEnd?: () => void, onQueryChange?: (query: string) => void }) ) { const [field, meta, helpers] = useField(props); const [query, setQuery] = useState(field.value as string ?? ""); const [internalData, setInternalData] = useState[]>([]); 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 <> > {...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} >
{title && ( )}
> 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 " />
< Combobox.Options tw="absolute z-10 max-h-60 w-full bg-trinary rounded-xl overflow-y-auto overflow-x-hidden" > {internalData?.map((val) => ( { renderOption ? (state) => renderOption(val as any, state) : {typeof val === "string" ? val : val.label} } ))} {onScrollEnd &&
}
{ meta?.touched && meta.error ? (
{meta.error}
) : null } ; }