@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.
270 lines (269 loc) • 10.3 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 { fieldNameToId, fieldNameToLabel, normalizeString } from "../../utils/text-transform.js";
import { useFieldIds } from "../../utils/useFieldIds.js";
import { createElement, forwardRef, useCallback, useContext, useState } from "react";
import { jsx, jsxs } from "react/jsx-runtime";
import { Controller } from "react-hook-form";
import Box from "@mui/material/Box";
import { useTheme } from "@mui/material/styles";
import TextField from "@mui/material/TextField";
import Chip from "@mui/material/Chip";
//#region src/mui/tags-input/index.tsx
const RHFTagsInput = forwardRef(function RHFTagsInput({ fieldName, control, registerOptions, onTagAdd, onTagDelete, onTagPaste, delimiter = ",", maxTags, onValueChange, disabled: muiDisabled, label, showLabelAboveFormField, formLabelProps, hideLabel, required, helperText, errorMessage, hideErrorMessage, formHelperTextProps, ChipProps, sx: muiTextFieldSx, variant = "outlined", limitTags = 2, getLimitTagsText, slotProps: muiSlotProps, onFocus, onBlur, autoComplete = "off", renderTagLabel, customIds, ...otherTagsInputProps }, ref) {
const muiTheme = useTheme();
const [inputValue, setInputValue] = useState("");
const [isFocused, setIsFocused] = useState(false);
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;
/**
* Similar to MuiAutocomplete, if limitTags = -1, show all the
* tags in the input, even when it is not focused.
*/
const showAllTags = limitTags === -1;
const getPaddingOverride = (overrides) => {
if (overrides !== null && typeof overrides === "object" && "padding" in overrides) {
const { padding } = overrides;
return typeof padding === "string" ? padding : void 0;
}
};
const getTextFieldPadding = (variant) => {
switch (variant) {
case "filled": return getPaddingOverride(muiTheme.components?.MuiFilledInput?.styleOverrides?.root) ?? "25px 12px 8px";
case "standard": return getPaddingOverride(muiTheme.components?.MuiInput?.styleOverrides?.root) ?? "4px 0px 5px";
default: return getPaddingOverride(muiTheme.components?.MuiOutlinedInput?.styleOverrides?.root) ?? "16.5px 14px";
}
};
const textFieldPadding = getTextFieldPadding(variant);
const handleFocus = (e) => {
setIsFocused(true);
onFocus?.(e);
};
const handleInputChange = (event) => {
setInputValue(event.target.value);
};
/** Helper for triggering both RHF + external change events */
const triggerChangeEvents = useCallback((newValue, onChange) => {
onChange(newValue);
onValueChange?.({ newValue });
}, [onValueChange]);
const handleKeyDown = useCallback((event, value, onChange) => {
const trimmed = inputValue.trim();
if (event.key === "Enter" || event.key === delimiter) {
event.preventDefault();
if (trimmed) {
const rawTags = trimmed.split(delimiter).map((tag) => tag.trim()).filter(Boolean);
if (!rawTags.length) return;
const processedTags = [];
for (const tag of rawTags) {
if (maxTags !== void 0 && value.length + processedTags.length >= maxTags) break;
const result = onTagAdd?.({
currentValue: value,
newTag: tag
});
if (result !== false) {
const finalTag = (typeof result === "string" ? result : tag).trim();
if (![...value, ...processedTags].some((v) => normalizeString(v) === normalizeString(finalTag))) processedTags.push(finalTag);
}
}
if (processedTags.length) {
triggerChangeEvents([...value, ...processedTags], onChange);
setInputValue("");
}
}
} else if (!trimmed && ["Backspace", "Delete"].includes(event.key)) {
/**
* Guard against empty array — value[value.length - 1] is
* undefined when there are no tags, which would pass undefined to
* onTagDelete and slice an already-empty array unnecessarily.
*/
if (!value.length) return;
const deletedTag = value[value.length - 1];
if (onTagDelete?.({
currentValue: value,
deletedTag
}) === false) return;
triggerChangeEvents(value.slice(0, -1), onChange);
}
}, [
inputValue,
delimiter,
maxTags,
onTagAdd,
onTagDelete,
triggerChangeEvents
]);
const handlePaste = useCallback((event, value, onChange) => {
event.preventDefault();
/**
* Use reduce instead of filter so each candidate tag is also
* checked against the tags already accepted from the same paste batch.
* Previously "foo,foo" would pass the filter twice because neither
* occurrence existed in `value` at filter time.
*/
const newTags = event.clipboardData.getData("text").split(delimiter).map((tag) => tag.trim()).filter(Boolean).reduce((acc, tag) => {
const norm = normalizeString(tag);
if (!value.some((v) => normalizeString(v) === norm) && !acc.some((v) => normalizeString(v) === norm)) acc.push(tag);
return acc;
}, []);
const result = onTagPaste?.({
currentValue: value,
pastedTags: newTags
});
if (result === false) return;
const finalTags = Array.isArray(result) ? result : newTags;
if (maxTags !== void 0) {
const remainingSlots = Math.max(maxTags - value.length, 0);
if (remainingSlots === 0) return;
if (finalTags.length > 0) triggerChangeEvents([...value, ...finalTags.slice(0, remainingSlots)], onChange);
return;
}
if (finalTags.length > 0) triggerChangeEvents([...value, ...finalTags], onChange);
}, [
delimiter,
maxTags,
onTagPaste,
triggerChangeEvents
]);
return /* @__PURE__ */ jsx(Controller, {
name: fieldName,
control,
rules: registerOptions,
render: ({ field: { name: rhfFieldName, value: rhfValue = [], onChange: rhfOnChange, onBlur: rhfOnBlur, ref: rhfRef, disabled: rhfDisabled }, fieldState: { error: fieldStateError } }) => {
const isDisabled = muiDisabled || rhfDisabled;
const fieldErrorMessage = fieldStateError?.message?.toString() ?? errorMessage;
const isError = !!fieldErrorMessage;
const showHelperTextElement = !!(helperText || isError && !hideErrorMessage);
const hideInput = muiDisabled && rhfValue.length > 0;
const visibleTags = showAllTags || isFocused ? rhfValue : rhfValue.slice(0, limitTags);
const moreTags = rhfValue.length - limitTags;
const startAdornment = /* @__PURE__ */ jsxs(Box, {
role: "list",
sx: {
display: "flex",
flexWrap: "wrap",
gap: 1,
mb: rhfValue.length > 0 && !hideInput ? 1 : 0,
width: "100%"
},
children: [visibleTags.map((tag, index) => /* @__PURE__ */ createElement(Chip, {
...ChipProps,
key: `${fieldNameToId(tag)}-${index}`,
id: `${fieldNameToId(tag)}-${index}`,
role: "listitem",
label: renderTagLabel?.(tag) ?? tag,
disabled: isDisabled,
onDelete: () => {
if (onTagDelete?.({
currentValue: rhfValue,
deletedTag: tag
}) === false) return;
triggerChangeEvents(rhfValue.filter((t) => t !== tag), rhfOnChange);
}
})), !showAllTags && !isFocused && rhfValue.length > limitTags && /* @__PURE__ */ jsx(Chip, {
...ChipProps,
id: `${fieldNameToId(fieldName)}-more`,
role: "listitem",
label: getLimitTagsText?.(moreTags) ?? `+${moreTags} more`,
disabled: isDisabled
})]
});
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, {
...otherTagsInputProps,
id: fieldId,
name: rhfFieldName,
autoComplete,
inputRef: mergeRefs(rhfRef, ref),
variant,
label: !hideLabel && !isLabelAboveFormField ? /* @__PURE__ */ jsx(FormLabelText, {
label: fieldLabel,
required
}) : void 0,
value: inputValue,
onChange: handleInputChange,
onKeyDown: (event) => handleKeyDown(event, rhfValue, rhfOnChange),
onPaste: (event) => handlePaste(event, rhfValue, rhfOnChange),
onFocus: handleFocus,
onBlur: (e) => {
setIsFocused(false);
/**
* Clear uncommitted input on blur so stale text
* doesn't reappear the next time the field is focused.
*/
setInputValue("");
rhfOnBlur();
onBlur?.(e);
},
disabled: isDisabled,
error: isError,
sx: {
...muiTextFieldSx,
"& .MuiInputBase-root": {
display: "flex",
flexDirection: "column",
padding: textFieldPadding
},
"& .MuiInputBase-input": {
padding: 0,
...hideInput && { display: "none" }
}
},
slotProps: {
...muiSlotProps,
input: {
...muiSlotProps?.input,
startAdornment
},
htmlInput: {
...muiSlotProps?.htmlInput,
"aria-labelledby": !hideLabel && isLabelAboveFormField ? labelId : void 0,
"aria-label": hideLabel ? accessibleFieldLabel : void 0,
"aria-describedby": showHelperTextElement ? isError ? errorId : helperTextId : void 0,
"aria-required": required
}
},
multiline: false
}),
/* @__PURE__ */ jsx(FormHelperText, {
error: isError,
errorMessage: fieldErrorMessage,
hideErrorMessage,
helperText,
showHelperTextElement,
formHelperTextProps: {
...formHelperTextProps,
id: isError ? errorId : helperTextId
}
})
]
});
}
});
});
//#endregion
export { RHFTagsInput as default };