"use client"; import { clsx } from "clsx"; import { createContext, ReactNode, useCallback, useContext } from "react"; import { FieldPathByValue, FieldValues, useFormContext } from "react-hook-form"; import Select, { components, type OptionProps, type Props as SelectProps, type SingleValueProps, } from "react-select"; import AsyncSelect from "react-select/async"; import { ControlledFormField } from "../common/ControlledFormField.js"; import { FormFieldLayoutProps } from "../common/FormFieldLayout.js"; type Equal = T1 extends T2 ? (T2 extends T1 ? true : false) : false; type OptionValueCodec = { optionToFieldValue: (option: TOption) => NonNullable; // Note that null result is allowed. See the comment below on null `selectValue`. fieldValueToOption: (value: NonNullable) => TOption | null; }; /* Useful for when form field's value doesn't match the option. * For example, since react-select requires options to be objects (not plain strings), * `SelectStringFormField` component uses these. * We allow these two props to be optional if and only if TOption and TValue have the same shape. */ type OptionValueCodecProps = Equal> extends true ? Partial> : OptionValueCodec; type SelectFormFieldContextShape = { renderOption?: (option: TOption) => ReactNode; }; // `createContext` doesn't play well with generic shapes, and I'm not sure how risky a factory function would be, so this is not strongly typed. const SelectFormFieldContext = createContext< SelectFormFieldContextShape >({ renderOption: undefined, }); function OptionComponent({ children, ...props }: OptionProps) { const { renderOption } = useContext(SelectFormFieldContext); return ( {renderOption ? renderOption(props.data) : children} ); } function SingleValueComponent({ children, ...props }: SingleValueProps) { const { renderOption } = useContext(SelectFormFieldContext); return ( {renderOption ? renderOption(props.data) : children} ); } export function SelectFormField< TValues extends FieldValues, TValue = never, TOption extends object = NonNullable, TName extends FieldPathByValue = FieldPathByValue< TValues, TValue >, >({ name, options, required = false, disabled, size = "normal", renderOption, async = false, loadOptions, getOptionLabel, getOptionValue, placeholder, optionToFieldValue: maybeOptionToFieldValue, fieldValueToOption: maybeFieldValueToOption, ...layoutProps }: FormFieldLayoutProps & { name: TName; required?: boolean; disabled?: boolean; // This affects only the outer Control height; resizing options with `renderOption` is your responsibility. // See SelectStringFormField for a example. size?: "normal" | "small"; renderOption?: (option: TOption) => ReactNode; } & ( | { async?: false; loadOptions?: undefined; options: readonly TOption[]; } | { async: true; loadOptions: (inputValue: string) => Promise; options?: undefined; } ) & Pick< SelectProps, "getOptionLabel" | "getOptionValue" | "placeholder" > & OptionValueCodecProps) { const { resetField } = useFormContext(); const SelectComponent = async ? AsyncSelect : Select; const fieldValueToOption = useCallback( (value: NonNullable): TOption | null => { if (value === undefined || value === null) { return null; } return ( // if fieldValueToOption is not set, TValue == TOption maybeFieldValueToOption?.(value) ?? (value as unknown as TOption) ); }, [maybeFieldValueToOption] ); const optionToFieldValue = useCallback( (option: TOption): NonNullable => { return ( // if optionToFieldValue is not set, TValue == TOption maybeOptionToFieldValue?.(option) ?? (option as unknown as NonNullable) ); }, [maybeOptionToFieldValue] ); return ( ReactNode) | undefined, }} > name={name} rules={{ required }} {...layoutProps} > {({ name, value, onChange }) => { /* `selectValue` can be null while `value` is not null. * This can happen if `fieldValueToOption` looks for an option in a fixed list, but value is not present there anymore. * This is bad: it means that the UI will show that nothing is selected, while the underlying form state still contains a value. * It might be useful to call `resetField` in that case, but it's risky since it can lead to an infinite loop. */ const selectValue = fieldValueToOption(value); const selectOnChange = (option: TOption | null) => { if (option) { onChange(optionToFieldValue(option)); } else { resetField(name); } }; return ( instanceId={name} // important to avoid hydration errors name={name} components={{ Option: OptionComponent, SingleValue: SingleValueComponent, }} value={selectValue} onChange={selectOnChange} options={options} loadOptions={loadOptions} defaultOptions={async ? true : undefined} placeholder={placeholder} isDisabled={disabled} isClearable={!required} getOptionLabel={getOptionLabel} getOptionValue={getOptionValue} unstyled classNames={{ // based on StyledInput styles control: () => clsx( size === "small" && "h-8", size === "normal" && "h-10", /* react-select sets min-height even in unstyled mode. * We could get rid of `!` modifier with this: * https://github.com/JedWatson/react-select/blob/2f94e8d228ea32dbd0faa1f7685a67b74c70420f/storybook/stories/ClassNamesWithTailwind.stories.tsx#L19 * But it would require @emotion/cache dependency and also I'm unsure about performance implications. */ "!min-h-0", "bg-white border-slate-300 border rounded-md shadow-sm focus-within:ring-indigo-500 focus-within:border-indigo-500 focus-within:ring-1" ), // disable default browser focus style input: () => "[&_input:focus]:!ring-transparent", placeholder: () => clsx("text-slate-300", size === "small" && "text-sm"), valueContainer: () => "px-3", clearIndicator: () => "text-slate-300 hover:text-slate-500 px-2", loadingIndicator: () => "text-slate-300 hover:text-slate-500 px-2", indicatorSeparator: () => "w-px bg-slate-300 my-2", dropdownIndicator: () => "text-slate-300 hover:text-slate-500 px-2", menuPortal: () => "!z-[100]", // based on Dropdown styles menu: () => "mt-2 rounded-md bg-white shadow-xl border border-slate-300 overflow-hidden", menuList: () => "p-1 overflow-auto", option: () => "px-2 py-1.5 rounded hover:bg-blue-100 text-slate-700 hover:text-slate-900", loadingMessage: () => "text-slate-500", noOptionsMessage: () => "text-slate-400 p-2", }} menuPortalTarget={ typeof document === "undefined" ? undefined : document.body } /> ); }} ); }