@nish1896/rhf-mui-components
Version:
A suite of 25+ production-ready react-hook-form components built with material-ui. Fully typed, tree-shakable, and optimized for enterprise-grade forms.
306 lines (305 loc) • 11.9 kB
JavaScript
"use client";
import { RHFMuiConfigContext } from "../../config/ConfigProvider.js";
import { keepLabelAboveFormField, mergeRefs } from "../../utils/control.js";
import FormControl from "../../common/FormControl.js";
import FormHelperText from "../../common/FormHelperText.js";
import FormLabel from "../../common/FormLabel.js";
import FormLabelText from "../../common/FormLabelText.js";
import { fieldNameToLabel } from "../../utils/text-transform.js";
import { useFieldIds } from "../../utils/useFieldIds.js";
import CountryMenuItem from "./CountryMenuItem.js";
import { forwardRef, useContext, useEffect, useMemo, useRef, useState } from "react";
import { jsx, jsxs } from "react/jsx-runtime";
import { Controller, useWatch } from "react-hook-form";
import TextField from "@mui/material/TextField";
import InputAdornment from "@mui/material/InputAdornment";
import MenuItem from "@mui/material/MenuItem";
import MuiSelect from "@mui/material/Select";
import Divider from "@mui/material/Divider";
import ListSubheader from "@mui/material/ListSubheader";
import { FlagImage, defaultCountries, parseCountry, usePhoneInput } from "react-international-phone";
import "react-international-phone/style.css";
//#region src/misc/phone-input/index.tsx
/**
* Code Reference -
* https://react-international-phone.vercel.app/docs/Advanced%20Usage/useWithUiLibs
*/
const countryMenuWidth = 350;
const countryMenuLeftOffset = -34;
const countryMenuViewportGutter = 32;
function toStructuredValue(phoneData) {
const { phone, country } = phoneData;
const phoneNo = phone.startsWith(`+${country.dialCode}`) ? phone.slice(country.dialCode.length + 1) : phone;
return {
phone,
country: country.iso2,
dialCode: country.dialCode,
phoneNo
};
}
function getPhoneValue(value) {
if (typeof value === "string") return value;
if (value && typeof value === "object" && "phone" in value) return String(value.phone ?? "");
}
const RHFPhoneInput = forwardRef(function RHFPhoneInput({ fieldName, control, registerOptions, customOnChange, onValueChange, label, showLabelAboveFormField, formLabelProps, hideLabel, required, helperText, hideErrorMessage, formHelperTextProps, disabled: muiDisabled, phoneInputProps, slotProps, onBlur, autoComplete = "off", customIds, searchCountryProps, ...otherRHFPhoneInputProps }, ref) {
const { fieldId, labelId, helperTextId, errorId } = useFieldIds(fieldName, customIds);
const { allLabelsAboveFields } = useContext(RHFMuiConfigContext);
const isLabelAboveFormField = keepLabelAboveFormField(showLabelAboveFormField, allLabelsAboveFields);
const defaultFieldLabel = fieldNameToLabel(fieldName);
const fieldLabel = label ?? defaultFieldLabel;
const accessibleFieldLabel = typeof fieldLabel === "string" ? fieldLabel : defaultFieldLabel;
const currentPhoneValue = getPhoneValue(useWatch({
control,
name: fieldName
}));
const [countrySearch, setCountrySearch] = useState("");
const [countryMenuLeft, setCountryMenuLeft] = useState(0);
const phoneInputRootRef = useRef(null);
const phoneChangeHandlerRef = useRef(null);
const { countries, preferredCountries, forceDialCode, ...otherPhoneInputProps } = phoneInputProps ?? {};
const countryOptions = countries ?? defaultCountries;
const { textFieldProps: searchCountryTextFieldProps, allowCountrySearch = true, renderCountryMenuItem, noCountryFoundText = "No countries found" } = searchCountryProps ?? {};
const { id: searchCountryTextFieldId = `${fieldName}_search-country`, fullWidth: searchCountryFullWidth = true, size: searchCountrySize = "small", placeholder: searchCountryPlaceholder = "Search by country or dial code", onChange: searchCountryOnChange, onClick: searchCountryOnClick, onKeyDown: searchCountryOnKeyDown, ...otherSearchCountryTextFieldProps } = searchCountryTextFieldProps ?? {};
const updateCountryMenuLeft = () => {
const inputWidth = phoneInputRootRef.current?.offsetWidth ?? 0;
const hasViewportRoom = window.innerWidth > countryMenuWidth + Math.abs(countryMenuLeftOffset) + countryMenuViewportGutter;
setCountryMenuLeft(inputWidth > countryMenuWidth && hasViewportRoom ? countryMenuLeftOffset : 0);
};
useEffect(() => {
updateCountryMenuLeft();
window.addEventListener("resize", updateCountryMenuLeft);
return () => {
window.removeEventListener("resize", updateCountryMenuLeft);
};
}, []);
/**
* Render preferred countries at the top of the list.
* Preferred countries will maintain the order in which they were
* specified in the props, while other countries will be sorted
* alphabetically.
*/
const { countriesToList, countriesToListAtTop } = useMemo(() => {
if (!preferredCountries?.length) return {
countriesToList: countryOptions,
countriesToListAtTop: []
};
const countriesToListAtTop = countryOptions.filter((country) => preferredCountries.includes(parseCountry(country).iso2)).sort((a, b) => preferredCountries.indexOf(parseCountry(a).iso2) - preferredCountries.indexOf(parseCountry(b).iso2));
return {
countriesToList: countryOptions.filter((country) => !preferredCountries.includes(parseCountry(country).iso2)),
countriesToListAtTop
};
}, [countryOptions, preferredCountries]);
const filterCountry = (countryData) => {
const search = countrySearch.trim().toLowerCase();
if (!search) return true;
const countryInfo = parseCountry(countryData);
const dialCodeSearch = search.replace("+", "");
return countryInfo.name.toLowerCase().includes(search) || countryInfo.iso2.toLowerCase().includes(search) || countryInfo.dialCode.includes(dialCodeSearch);
};
const filteredCountriesToListAtTop = countriesToListAtTop.filter(filterCountry);
const filteredCountriesToList = countriesToList.filter(filterCountry);
const { inputValue, handlePhoneValueChange, inputRef, country, setCountry } = usePhoneInput({
...otherPhoneInputProps,
value: currentPhoneValue,
onChange: (phoneData) => {
phoneChangeHandlerRef.current?.(phoneData);
},
countries: countryOptions,
preferredCountries,
forceDialCode
});
return /* @__PURE__ */ jsx(Controller, {
name: fieldName,
control,
rules: registerOptions,
render: ({ field: { name: rhfFieldName, onChange: rhfOnChange, onBlur: rhfOnBlur, ref: rhfRef, disabled: rhfDisabled }, fieldState: { error: fieldStateError } }) => {
const isDisabled = muiDisabled || rhfDisabled;
const fieldErrorMessage = fieldStateError?.message?.toString();
const isError = !!fieldErrorMessage;
const showHelperTextElement = !!(helperText || isError && !hideErrorMessage);
const handleChange = (phoneData) => {
const newValue = toStructuredValue(phoneData);
if (customOnChange) {
customOnChange({
rhfOnChange,
newValue,
phoneData
});
return;
}
rhfOnChange(newValue);
onValueChange?.({
newValue,
phoneData
});
};
phoneChangeHandlerRef.current = handleChange;
const startAdornment = /* @__PURE__ */ jsx(InputAdornment, {
position: "start",
style: {
marginRight: "2px",
marginLeft: "-8px"
},
children: /* @__PURE__ */ jsxs(MuiSelect, {
MenuProps: {
autoFocus: false,
PaperProps: { sx: {
width: `min(${countryMenuWidth}px, calc(100vw - 32px))`,
maxWidth: "calc(100vw - 32px)",
maxHeight: 300
} },
MenuListProps: { sx: { pt: allowCountrySearch ? 0 : "8px" } },
style: {
top: "10px",
left: countryMenuLeft
},
transformOrigin: {
vertical: "top",
horizontal: "left"
}
},
sx: {
width: "max-content",
fieldset: { display: "none" },
"&.Mui-focused:has(div[aria-expanded=\"false\"])": { fieldset: { display: "block" } },
".MuiSelect-select": {
padding: "8px",
paddingRight: "24px !important"
},
svg: { right: 0 }
},
value: country.iso2,
disabled: isDisabled || forceDialCode,
onOpen: updateCountryMenuLeft,
onClose: () => {
setCountrySearch("");
},
onChange: (e) => {
setCountry(e.target.value, { focusOnInput: true });
},
renderValue: (value) => /* @__PURE__ */ jsx(FlagImage, {
iso2: value,
style: { display: "flex" }
}),
children: [
allowCountrySearch && /* @__PURE__ */ jsx(ListSubheader, {
sx: {
position: "sticky",
top: 0,
zIndex: 1,
bgcolor: "background.paper",
lineHeight: "normal",
padding: "8px"
},
children: /* @__PURE__ */ jsx(TextField, {
...otherSearchCountryTextFieldProps,
label: null,
id: searchCountryTextFieldId,
fullWidth: searchCountryFullWidth,
placeholder: searchCountryPlaceholder,
size: searchCountrySize,
value: countrySearch,
onChange: (event) => {
setCountrySearch(event.target.value);
searchCountryOnChange?.(event);
},
onClick: (event) => {
event.stopPropagation();
searchCountryOnClick?.(event);
},
onKeyDown: (event) => {
event.stopPropagation();
searchCountryOnKeyDown?.(event);
}
})
}),
filteredCountriesToListAtTop.map((c) => {
const countryInfo = parseCountry(c);
return /* @__PURE__ */ jsx(MenuItem, {
value: countryInfo.iso2,
children: renderCountryMenuItem?.(countryInfo) ?? /* @__PURE__ */ jsx(CountryMenuItem, { country: countryInfo })
}, countryInfo.iso2);
}),
filteredCountriesToListAtTop.length > 0 && filteredCountriesToList.length > 0 && /* @__PURE__ */ jsx(Divider, {}),
filteredCountriesToList.map((c) => {
const countryInfo = parseCountry(c);
return /* @__PURE__ */ jsx(MenuItem, {
value: countryInfo.iso2,
children: renderCountryMenuItem?.(countryInfo) ?? /* @__PURE__ */ jsx(CountryMenuItem, { country: countryInfo })
}, countryInfo.iso2);
}),
filteredCountriesToListAtTop.length === 0 && filteredCountriesToList.length === 0 && /* @__PURE__ */ jsx(MenuItem, {
disabled: true,
children: noCountryFoundText
})
]
})
});
return /* @__PURE__ */ jsxs(FormControl, {
error: isError,
disabled: isDisabled,
children: [
!hideLabel && /* @__PURE__ */ jsx(FormLabel, {
label: fieldLabel,
isVisible: isLabelAboveFormField,
required,
error: isError,
disabled: isDisabled,
formLabelProps: {
...formLabelProps,
id: labelId,
htmlFor: fieldId
}
}),
/* @__PURE__ */ jsx(TextField, {
...otherRHFPhoneInputProps,
ref: phoneInputRootRef,
id: fieldId,
name: rhfFieldName,
inputRef: mergeRefs(rhfRef, inputRef, ref),
value: inputValue,
autoComplete,
type: "tel",
onChange: (e) => {
handlePhoneValueChange(e);
},
onBlur: (blurEvent) => {
rhfOnBlur();
onBlur?.(blurEvent);
},
label: !hideLabel && !isLabelAboveFormField ? /* @__PURE__ */ jsx(FormLabelText, {
label: fieldLabel,
required
}) : void 0,
"aria-labelledby": !hideLabel && isLabelAboveFormField ? labelId : void 0,
"aria-label": hideLabel ? accessibleFieldLabel : void 0,
"aria-describedby": showHelperTextElement ? isError ? errorId : helperTextId : void 0,
"aria-required": required,
error: isError,
disabled: isDisabled,
slotProps: {
...slotProps,
input: {
...slotProps?.input,
startAdornment
}
}
}),
/* @__PURE__ */ jsx(FormHelperText, {
error: isError,
errorMessage: fieldErrorMessage,
hideErrorMessage,
helperText,
showHelperTextElement,
formHelperTextProps: {
...formHelperTextProps,
id: isError ? errorId : helperTextId
}
})
]
});
}
});
});
//#endregion
export { RHFPhoneInput as default };