From 8b86cbd534f1e5cc2a089d995e6f4f069b832296 Mon Sep 17 00:00:00 2001 From: honzapatCZ Date: Wed, 27 May 2026 22:43:37 +0200 Subject: [PATCH] improve Autocomplete --- Form/Core/AutocompleteSelect.js | 60 +++++++++++++++++++++++---------- 1 file changed, 43 insertions(+), 17 deletions(-) diff --git a/Form/Core/AutocompleteSelect.js b/Form/Core/AutocompleteSelect.js index bebe77a..376a359 100644 --- a/Form/Core/AutocompleteSelect.js +++ b/Form/Core/AutocompleteSelect.js @@ -1,17 +1,35 @@ import { Combobox } from "@headlessui/react"; import { DropDownItem } from "@shared/nej-react-components/Parts/DropDown"; -import { useEffect, useState } from "react"; +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, ...props }) { +/** + * @param {object} props + * @param {((query: string) => Promise) | Array} props.data + * @param {(val: any, state: { active: boolean, selected: boolean }) => React.ReactNode} [props.renderOption] - Custom renderer for each option. Receives the raw item and combobox state. + * @param {() => void} [props.onScrollEnd] - Called when the options list is scrolled to the bottom (for infinite scroll). + * @param {(query: string) => void} [props.onQueryChange] - Called when the search input changes. Use this with renderOption to drive server-side filtering. + */ +export function AutocompleteSelect({ title, titleProps = null, data, nullable = false, renderOption, onScrollEnd, onQueryChange, ...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 () => { @@ -19,16 +37,21 @@ export function AutocompleteSelect({ title, titleProps = null, data, nullable = //if data is async function await it setInternalData(await data(query)); } else if (Array.isArray(data)) { - setInternalData( - query === "" - ? data - : data.filter((item) => { - if (typeof item === "string") - return item.toLowerCase().includes(query.toLowerCase()); + // 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()); - }) - ); + return item.label.toLowerCase().includes(query.toLowerCase()); + }) + ); + } } else if(typeof data === "object") { setInternalData(Object.values(data).map((value) => { @@ -60,8 +83,9 @@ export function AutocompleteSelect({ title, titleProps = null, data, nullable = : null } onChange={(value) => { - console.log(value); - field.onChange({ target: { value: value, name: field.name } }); + // 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} > @@ -80,7 +104,7 @@ export function AutocompleteSelect({ title, titleProps = null, data, nullable =
setQuery(event.target.value)} + onChange={(event) => { setQuery(event.target.value); onQueryChange?.(event.target.value); }} displayValue={(val) => (typeof val === "string" ? val : val?.label)} /> @@ -92,11 +116,13 @@ export function AutocompleteSelect({ title, titleProps = null, data, nullable = key={typeof val === "string" ? val : val.value} value={typeof val === "string" ? val : val.value} > - - {typeof val === "string" ? val : val.label} - + {renderOption + ? (state) => renderOption(val, state) + : {typeof val === "string" ? val : val.label} + } ))} + {onScrollEnd &&
}