UNPKG

@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
"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 };