moving autocmplete to tsx
This commit is contained in:
parent
16a81d9eb7
commit
4208d617fa
|
|
@ -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 (
|
|
||||||
<>
|
|
||||||
<Combobox
|
|
||||||
{...props}
|
|
||||||
{...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
|
|
||||||
const stored = value != null && typeof value === "object" ? value.value : value;
|
|
||||||
field.onChange({ target: { value: stored, name: field.name } });
|
|
||||||
}}
|
|
||||||
nullable={nullable}
|
|
||||||
>
|
|
||||||
<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
|
|
||||||
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.value}
|
|
||||||
value={typeof val === "string" ? val : val.value}
|
|
||||||
>
|
|
||||||
{renderOption
|
|
||||||
? (state) => renderOption(val, 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}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
143
Form/Core/AutocompleteSelect.tsx
Normal file
143
Form/Core/AutocompleteSelect.tsx
Normal file
|
|
@ -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<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
|
||||||
|
}
|
||||||
|
</>;
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user