@baseplate-dev/ui-components
Version:
Shared UI component library
184 lines • 8.66 kB
JavaScript
'use client';
import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
import { useEffect, useMemo, useRef, useState } from 'react';
import { useComponentStrings } from '#src/contexts/component-strings.js';
import { useControllerMerged } from '#src/hooks/use-controller-merged.js';
import { useDebounce } from '#src/hooks/use-debounce.js';
import { Combobox, ComboboxContent, ComboboxEmpty, ComboboxInput, ComboboxItem, ComboboxLoading, } from '../combobox/combobox.js';
import { FormControl, FormDescription, FormItem, FormLabel, FormMessage, } from '../form-item/form-item.js';
/**
* Field with label and error states that wraps a Combobox component with async option loading.
*/
function AsyncComboboxField({ label, description, error, value, placeholder, renderItemLabel, onChange, getOptionLabel = (val) => val.label, getOptionValue = (val) => val.value, className, noResultsText, loadingText, errorText, formatError, loadOptions, resolveValue, debounceMs = 300, loadingDelay = 200, minSearchLength = 0, initialOptions = [], ...props }) {
const [options, setOptions] = useState(initialOptions);
const [searchQuery, setSearchQuery] = useState('');
const [isLoading, setIsLoading] = useState(false);
const [loadError, setLoadError] = useState(null);
// Cache the selected option to persist it across searches
const [selectedOptionCache, setSelectedOptionCache] = useState(null);
const debouncedSearchQuery = useDebounce(searchQuery, debounceMs);
const { comboboxNoResults, comboboxLoading } = useComponentStrings();
const loadOptionsRef = useRef(loadOptions);
loadOptionsRef.current = loadOptions;
const initialOptionsRef = useRef(initialOptions);
initialOptionsRef.current = initialOptions;
const formatErrorRef = useRef(formatError);
formatErrorRef.current = formatError;
// Handle external value changes and try to resolve the option
useEffect(() => {
if (value === null || value === undefined) {
setSelectedOptionCache(null);
return;
}
// Check if we already have this option cached
if (selectedOptionCache && getOptionValue(selectedOptionCache) === value) {
return;
}
// Try to find the option in current options
const optionInResults = options.find((o) => getOptionValue(o) === value);
if (optionInResults) {
setSelectedOptionCache(optionInResults);
return;
}
// Try to find in initial options
const optionInInitial = initialOptions.find((o) => getOptionValue(o) === value);
if (optionInInitial) {
setSelectedOptionCache(optionInInitial);
return;
}
// If we have a resolveValue function, try to resolve the option
if (resolveValue) {
resolveValue(value)
.then((resolvedOption) => {
if (resolvedOption && getOptionValue(resolvedOption) === value) {
setSelectedOptionCache(resolvedOption);
}
})
.catch(() => {
// If resolution fails, we'll show the value as-is
});
}
}, [
value,
options,
initialOptions,
selectedOptionCache,
getOptionValue,
resolveValue,
]);
useEffect(() => {
let isAborted = false;
// Skip loading if search query is shorter than minimum length
if (debouncedSearchQuery.length < minSearchLength) {
setOptions(initialOptions);
setLoadError(null);
setIsLoading(false);
return;
}
// Clear any previous error state
setLoadError(null);
// Set up loading timeout to prevent flashing for fast requests
const loadingTimeout = setTimeout(() => {
setIsLoading(true);
}, loadingDelay);
// Execute the async request
loadOptionsRef
.current(debouncedSearchQuery)
.then((newOptions) => {
if (!isAborted) {
setOptions(newOptions);
}
})
.catch((err) => {
if (!isAborted) {
const errorMessage = formatErrorRef.current
? formatErrorRef.current(err)
: err instanceof Error
? err.message
: 'Failed to load options';
setLoadError(errorMessage);
setOptions([]);
}
})
.finally(() => {
clearTimeout(loadingTimeout);
setIsLoading(false);
});
// Cleanup function to handle cancellation
return () => {
clearTimeout(loadingTimeout);
isAborted = true;
};
}, [debouncedSearchQuery, minSearchLength, loadingDelay]);
const selectedOption = useMemo(() => {
if (value === null || value === undefined)
return null;
// First priority: option from cache (persists across searches)
if (selectedOptionCache && getOptionValue(selectedOptionCache) === value) {
return selectedOptionCache;
}
// Second priority: option from current search results
const optionInResults = options.find((o) => getOptionValue(o) === value);
if (optionInResults) {
return optionInResults;
}
// Third priority: option from initial options
const optionInInitial = initialOptions.find((o) => getOptionValue(o) === value);
if (optionInInitial) {
return optionInInitial;
}
return null;
}, [value, selectedOptionCache, options, initialOptions, getOptionValue]);
const selectedComboboxOption = useMemo(() => {
if (value === undefined)
return;
if (selectedOption) {
return {
label: getOptionLabel(selectedOption),
value: getOptionValue(selectedOption),
};
}
// Fallback: if we have a value but no option, show the value as the label
if (value !== null) {
return {
label: value,
value,
};
}
return null;
}, [value, selectedOption, getOptionLabel, getOptionValue]);
const handleSearchQueryChange = (query) => {
setSearchQuery(query);
};
const renderContent = () => {
if (isLoading) {
return (_jsx(ComboboxLoading, { children: loadingText ?? comboboxLoading }));
}
if (loadError) {
return (_jsx("div", { role: "alert", className: "p-4 text-sm text-destructive", children: errorText ?? loadError }));
}
return (_jsxs(_Fragment, { children: [options.map((option) => {
const val = getOptionValue(option);
const label = getOptionLabel(option);
return (_jsx(ComboboxItem, { value: val, label: label, children: renderItemLabel
? renderItemLabel(option, { selected: val === value })
: label }, val));
}), _jsx(ComboboxEmpty, { children: noResultsText ?? comboboxNoResults })] }));
};
return (_jsxs(FormItem, { error: error, className: className, children: [_jsx(FormLabel, { children: label }), _jsxs(Combobox, { value: selectedComboboxOption, onChange: (comboboxValue) => {
const selectedValue = comboboxValue.value;
// Find the full option object that was selected and cache it
const fullOption = options.find((o) => getOptionValue(o) === selectedValue);
if (fullOption) {
setSelectedOptionCache(fullOption);
}
onChange?.(selectedValue);
}, searchQuery: searchQuery, onSearchQueryChange: handleSearchQueryChange, ...props, children: [_jsx(FormControl, { children: _jsx(ComboboxInput, { placeholder: placeholder }) }), _jsx(ComboboxContent, { children: renderContent() })] }), _jsx(FormDescription, { children: description }), _jsx(FormMessage, {})] }));
}
function AsyncComboboxFieldController({ name, control, ...rest }) {
const { field, fieldState: { error }, } = useControllerMerged({ name, control }, rest);
const restProps = rest;
return (_jsx(AsyncComboboxField, { error: error?.message ?? undefined, ...restProps, ...field, value: field.value ?? null }));
}
export { AsyncComboboxField, AsyncComboboxFieldController };
//# sourceMappingURL=async-combobox-field.js.map