diff --git a/Form/Core/AutocompleteSelect.js b/Form/Core/AutocompleteSelect.js deleted file mode 100644 index f12fd59..0000000 --- a/Form/Core/AutocompleteSelect.js +++ /dev/null @@ -1,128 +0,0 @@ -import { Combobox } from "@headlessui/react"; - -import { DropDownItem } from "@shared/nej-react-components/Parts/DropDown"; -import { useEffect, useRef, useState } from "react"; - -import tw, { styled } from "twin.macro"; -import "styled-components/macro"; -import { useField } from "@shared/nej-react-components/Parts/Input"; - -export function AutocompleteSelect({ title, titleProps = null, data, nullable = false, renderOption = undefined, onScrollEnd = undefined, onQueryChange = undefined, ...props }) { - const [field, meta, helpers] = useField(props); - - const [query, setQuery] = useState(field.value ?? ""); - 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 - ); - } - else { - console.error("data is not an array or function :c"); - setInternalData([]); - } - })(); - }, [query, data]); - - return ( - <> - - 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 - const stored = value != null && typeof value === "object" ? value.value : value; - field.onChange({ target: { value: stored, name: field.name } }); - }} - nullable={nullable} - > -
- {title && ( - - )} -
- { setQuery(event.target.value); onQueryChange?.(event.target.value); }} - displayValue={(val) => (typeof val === "string" ? val : val?.label)} - /> - -
- - {internalData?.map((val) => ( - - {renderOption - ? (state) => renderOption(val, state) - : {typeof val === "string" ? val : val.label} - } - - ))} - {onScrollEnd &&
} - -
- - - {meta?.touched && meta.error ? ( -
{meta.error}
- ) : null} - - ); -} diff --git a/Form/Core/AutocompleteSelect.tsx b/Form/Core/AutocompleteSelect.tsx new file mode 100644 index 0000000..738db04 --- /dev/null +++ b/Form/Core/AutocompleteSelect.tsx @@ -0,0 +1,143 @@ +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 + } + ; +}