mirror of
https://github.com/TheRealOwenRees/echecsfrance.git
synced 2026-07-23 04:26:57 +00:00
f2a245ba6d
Strongly type form components
188 lines
5.6 KiB
TypeScript
188 lines
5.6 KiB
TypeScript
import React from "react";
|
|
|
|
import { flatMap, get, isArray, isNil, isObject } from "lodash";
|
|
import { useTranslations } from "next-intl";
|
|
import {
|
|
Controller,
|
|
FieldPath,
|
|
FieldValues,
|
|
useFormContext,
|
|
} from "react-hook-form";
|
|
import Select, {
|
|
ClassNamesConfig,
|
|
GroupBase,
|
|
OnChangeValue,
|
|
Props,
|
|
} from "react-select";
|
|
import { twMerge } from "tailwind-merge";
|
|
|
|
import { Prettify } from "@/types";
|
|
|
|
import { Field, GenericFieldProps } from "./Field";
|
|
|
|
export type BaseOption<T = string, D = unknown> = {
|
|
value: T;
|
|
label: string | JSX.Element;
|
|
disabled?: boolean;
|
|
data?: D;
|
|
};
|
|
|
|
export const classNames = <Option, IsMulti extends boolean = false>(
|
|
hasError: boolean,
|
|
): ClassNamesConfig<Option, IsMulti, GroupBase<Option>> => ({
|
|
container: () => "w-full",
|
|
valueContainer: () => "text-gray-900 dark:text-white",
|
|
indicatorsContainer: () => "flex items-center self-stretch shrink-0",
|
|
clearIndicator: () =>
|
|
"pointer-events-none flex items-center pr-2 text-gray-900 dark:text-white",
|
|
dropdownIndicator: () =>
|
|
"pointer-events-none flex items-center pr-2 text-gray-900 dark:text-white",
|
|
indicatorSeparator: () => "w-px text-gray-900 dark:text-white",
|
|
control: (state) =>
|
|
twMerge(
|
|
"group flex w-full items-center justify-between rounded-lg border p-3 text-sm",
|
|
"border-gray-300 bg-gray-50 text-gray-900 shadow-sm focus-within:border-primary-500 focus-within:ring-primary-500",
|
|
"dark:border-gray-600 dark:bg-gray-700 dark:text-white dark:placeholder-gray-400 dark:focus-within:border-primary-500 dark:focus-within:ring-primary-500",
|
|
|
|
hasError && "ring-1 ring-error",
|
|
state.isDisabled && "cursor-not-allowed",
|
|
state.isFocused && "border-primary ring ring-primary ring-opacity-50",
|
|
),
|
|
multiValue: () => "bg-fieldGray border rounded-lg flex space-x-1 pl-1 m-1",
|
|
multiValueLabel: () => "",
|
|
multiValueRemove: () => "items-center px-1 hover:text-primary",
|
|
placeholder: () =>
|
|
"block truncate pr-2 placeholder-gray dark:placeholder-gray-400",
|
|
menu: () =>
|
|
twMerge(
|
|
"!z-10 mt-2 rounded-lg border border-gray-200 bg-white p-1",
|
|
"border-gray-300 bg-gray-50 text-gray-900",
|
|
"dark:border-gray-600 dark:bg-gray-700 dark:text-white",
|
|
),
|
|
groupHeading: () => "ml-3 mt-2 mb-1 text-textGray text-sm uppercase",
|
|
option: ({ isFocused, isDisabled }) =>
|
|
twMerge(
|
|
"px-3 py-2 hover:cursor-pointer",
|
|
isDisabled && "opacity-50",
|
|
isFocused && "hover:bg-primary-500 hover:text-white",
|
|
),
|
|
noOptionsMessage: () =>
|
|
"text-textGray p-2 bg-gray-50 border border-dashed border-gray-200 rounded-sm",
|
|
});
|
|
|
|
export type SelectFieldProps<
|
|
TFieldValues extends FieldValues = FieldValues,
|
|
TFieldName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
|
|
IsMulti extends boolean = false,
|
|
T = string,
|
|
D = unknown,
|
|
> = Prettify<
|
|
GenericFieldProps<TFieldValues, TFieldName> &
|
|
Omit<
|
|
Props<BaseOption<T, D>, IsMulti, GroupBase<BaseOption<T, D>>>,
|
|
"onChange" | "value" | "classNames" | "name"
|
|
> & {
|
|
required?: boolean;
|
|
}
|
|
>;
|
|
|
|
export const SelectField = <
|
|
TFieldValues extends FieldValues = FieldValues,
|
|
TFieldName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
|
|
IsMulti extends boolean = false,
|
|
T = string,
|
|
D = unknown,
|
|
>({
|
|
name,
|
|
control,
|
|
className,
|
|
labelClassName,
|
|
childrenWrapperClassName,
|
|
label,
|
|
hideErrorMessage,
|
|
required,
|
|
|
|
placeholder,
|
|
options,
|
|
...selectProps
|
|
}: SelectFieldProps<TFieldValues, TFieldName, IsMulti, T, D>) => {
|
|
const t = useTranslations("App");
|
|
const form = useFormContext<TFieldValues>();
|
|
|
|
const {
|
|
formState: { errors },
|
|
} = form;
|
|
|
|
const hasError = !!get(errors, name)?.message;
|
|
|
|
const isGroup = (
|
|
option: BaseOption<T, D> | GroupBase<BaseOption<T, D>>,
|
|
): option is GroupBase<BaseOption<T, D>> =>
|
|
isObject(option) && "options" in option;
|
|
|
|
const flattenedOptions = flatMap(options, (option) => {
|
|
return isGroup(option) ? option.options : option;
|
|
});
|
|
|
|
const valueToOption = (value: T): BaseOption<T, D> =>
|
|
flattenedOptions.find((o) => o.value === value) ?? { value, label: "" };
|
|
|
|
return (
|
|
<Field
|
|
{...{
|
|
name,
|
|
control,
|
|
className,
|
|
label,
|
|
labelClassName,
|
|
childrenWrapperClassName,
|
|
hideErrorMessage,
|
|
required,
|
|
}}
|
|
>
|
|
<Controller
|
|
name={name}
|
|
control={control}
|
|
render={({ field: { onChange, value } }) => {
|
|
const onSelectChange = (
|
|
newValue: OnChangeValue<BaseOption<T, D>, IsMulti>,
|
|
) => {
|
|
if (isNil(newValue)) onChange(null);
|
|
else if (isArray(newValue)) {
|
|
onChange(newValue.map((option) => option.value));
|
|
} else onChange((newValue as BaseOption<T, D>).value);
|
|
};
|
|
|
|
const optionValue = isNil(value)
|
|
? null
|
|
: isArray(value)
|
|
? // @ts-ignore - this is too complex for TS to understand
|
|
value.map(valueToOption)
|
|
: valueToOption(value);
|
|
|
|
return (
|
|
<Select
|
|
value={optionValue}
|
|
onChange={onSelectChange}
|
|
options={options}
|
|
noOptionsMessage={() => t("noOptionsMessage")}
|
|
placeholder={placeholder ?? t("selectPlaceholder")}
|
|
unstyled
|
|
styles={{
|
|
input: (base) => ({
|
|
...base,
|
|
"input:focus": {
|
|
boxShadow: "none",
|
|
},
|
|
}),
|
|
}}
|
|
classNames={classNames<BaseOption<T, D>, IsMulti>(hasError)}
|
|
{...selectProps}
|
|
/>
|
|
);
|
|
}}
|
|
/>
|
|
</Field>
|
|
);
|
|
};
|