Search code examples
reactjstypescriptinputmaterial-ui

My custom-built input component only accepts one character and loses focus from the input field


I'm building UI components with React, Joy-UI and Storybook. Currently I can only type one character at a time, which seems to indicate that it is being re-rendered again. However I'm not sure what is causing this. I have taken inspiration to build the component from here: https://codesandbox.io/embed/kgz2w2?module=/src/Demo.tsx

Here is how the input component is implemented:

import { FormControl, FormHelperText, Input, InputProps, styled } from "@mui/joy";
import Image from "next/image";
import React from "react";
import errorhelper from "../../../../public/components/common/errorhelper.svg";
import search from "../../../../public/components/common/search.svg";
import { InnerInput } from "./styles/InnerInput";

type TextInputBaseProps = Pick<InputProps, "disabled" | "error" | "onChange" | "type" | "value"> & {
    /**If true the icon is shown in the start of the textfield */
    showIcon: boolean;
    /**If true the textfield is optional */
    optional: boolean;
    /**Shows the textfield in focus state */
    focus?: boolean;
    /**The helper text for input field in default state*/
    helperText?: string;
    /**The helper text for input field in error state */
    errorHelperText?: string;
    /**The label for the input field */
    label: string;
};

/** A custom textfield component using Joy-UI's Input. */
export const TextInput = ({
    label,
    errorHelperText,
    helperText,
    focus,
    showIcon,
    optional,
    ...props
}: TextInputBaseProps) => {
    return (
        <FormControl>
            <Input
                {...props}
                onChange={props.onChange}
                startDecorator={showIcon ? <Image src={search} alt={""} width={16} height={16} /> : null}
                endDecorator={optional ? "(Optional)" : null}
                sx={{
                    ...(focus && {
                        outline: "0.125rem solid var(--colorSystemColorsFocus500)",
                        outlineOffset: "-0.0625rem"
                    })
                }}
                slots={{
                    input: () => <InnerInput {...props} showIcon={showIcon} label={label} />
                }}
            />
            {helperText && !props.error && <FormHelperText>{helperText}</FormHelperText>}
            {errorHelperText && props.error && (
                <FormHelperText sx={{ color: "var(--colorSystemColorsDanger500)" }}>
                    <Image src={errorhelper} alt={""} width={16} height={16} /> {errorHelperText}
                </FormHelperText>
            )}
        </FormControl>
    );
};
import { styled } from "@mui/joy";
import React from "react";

// The StyledInput, StyledLabel and InnerInput is taken from here: https://mui.com/joy-ui/react-input/#floating-label
export const StyledInput = styled("input")({
    border: "none",
    minWidth: 0,
    outline: 0,
    padding: 0,
    paddingTop: "1em",
    flex: 1,
    color: "inherit",
    backgroundColor: "transparent",
    fontFamily: "inherit",
    fontSize: "inherit",
    fontStyle: "inherit",
    fontWeight: "inherit",
    lineHeight: "inherit",
    textOverflow: "ellipsis",
    "&::placeholder": {
        opacity: 0,
        transition: "0.1s ease-out"
    },
    "&:focus::placeholder": {
        opacity: 1
    },
    "&:focus ~ label": {
        color: "var(--colorTextSubtle1)",
        top: "0.5rem",
        fontSize: "0.75rem"
    },
    "&:-webkit-autofill": {
        alignSelf: "stretch"
    },
    "&:-webkit-autofill:not(* + &)": {
        marginInlineStart: "calc(-1 * var(--Input-paddingInline))",
        paddingInlineStart: "var(--Input-paddingInline)",
        borderTopLeftRadius: "calc(var(--Input-radius) - var(--variant-borderWidth, 0px))",
        borderBottomLeftRadius: "calc(var(--Input-radius) - var(--variant-borderWidth, 0px))"
    }
});

export const StyledLabel = styled("label")(({ theme }) => ({
    position: "absolute",
    lineHeight: 1,
    top: "calc((var(--Input-minHeight) - 1em) / 1.5)",
    color: "var(--colorTextSubtle1)",
    fontWeight: theme.vars.fontWeight.md,
    transition: "all 150ms cubic-bezier(0.4, 0, 0.2, 1)"
}));

export const InnerInput = React.forwardRef<
    HTMLInputElement,
    React.JSX.IntrinsicElements["input"] & { showIcon: boolean; label: string }
>(function InnerInput({ showIcon, label, ...props }, ref) {
    const id = React.useId();
    console.log(props.value);
    return (
        <React.Fragment>
            <StyledInput {...props} ref={ref} id={id} />
            <StyledLabel
                sx={{
                    marginLeft: showIcon ? "1.55rem" : 0,
                    fontSize: props.value ? "0.75rem" : "1rem",
                    top: props.value ? "0.5rem" : null
                }}
                htmlFor={id}>
                {label}
            </StyledLabel>
        </React.Fragment>
    );
});

And here is how its used:

import AgentTableComponent from "@/components/custom/agent/AgentTable";
import AssetTableComponent from "@/components/custom/asset/AssetTable";
import NotificationsComponent from "@/components/custom/Notifications";
import ProtocolTableComponent from "@/components/custom/protocol/ProtocolTable";
import AssetTabsView from "./AssetTabs.view";
import { TextInput } from "@/components/common/input/TextInput.component";
import { useState } from "react";

const cardStyle = {
    backgroundColor: "lightGrey",
    borderRadius: "1rem",
    padding: "1rem",
    overflow: "auto",
    maxHeight: "30rem",
    marginBottom: "2rem",
    color: "black"
};

export default function DebugListView() {
    const [name, setName] = useState("");

    const handleInput = (e: React.ChangeEvent<HTMLTextAreaElement | HTMLInputElement>) => {
        setName(e.target.value);
    };

    return (
        <>
            <div style={{ display: "flex" }}>
                <div
                    style={{
                        ...cardStyle,
                        flexBasis: "calc(50% - 2rem)",
                        marginRight: "2rem"
                    }}>
                    <h3 style={{ marginBottom: "0.5rem" }}>Notifications</h3>
                    <NotificationsComponent></NotificationsComponent>
                </div>
                <div style={{ ...cardStyle, flexBasis: "50%", minHeight: "30rem" }}>
                    <h3 style={{ marginBottom: "0.5rem" }}>Assets</h3>
                    <AssetTableComponent />
                    <TextInput onChange={handleInput} showIcon={true} optional={false} label={"name"} />
                </div>
            </div>

            <div style={{ display: "flex" }}>
                <div
                    style={{
                        ...cardStyle,
                        flexBasis: "calc(50% - 2rem)",
                        marginRight: "2rem"
                    }}>
                    <ProtocolTableComponent />
                </div>
                <div style={{ ...cardStyle, flexBasis: "50%" }}>
                    <h3 style={{ marginBottom: "0.5rem" }}>Protocols</h3>

                    <AssetTabsView />
                </div>
            </div>
            <div style={cardStyle}>
                <h3 style={{ marginBottom: "0.5rem" }}>Agents</h3>
                <AgentTableComponent></AgentTableComponent>
            </div>
        </>
    );
}

Solution

  • The issue is that you are declaring an inline function that returns the inner input. The anonymous function is a new reference each render cycle. You can fix this by passing InnerInput as the input slot component, and pass the additional props via the slotProps prop. See SlotProps for complete details.

    Example:

    export const TextInput = ({
      label,
      errorHelperText,
      helperText,
      focus,
      showIcon,
      optional,
      ...props
    }: TextInputBaseProps) => {
      return (
        <FormControl>
          <Input
            {...props}
            onChange={props.onChange}
            endDecorator={optional ? "(Optional)" : null}
            sx={{
              ...(focus && {
                outline: "0.125rem solid var(--colorSystemColorsFocus500)",
                outlineOffset: "-0.0625rem",
              }),
            }}
            slotProps={{
              input: { showIcon, label },  // <-- pass component props
            }}
            slots={{
              input: InnerInput,           // <-- pass component reference
            }}
          />
          {helperText && !props.error && (
            <FormHelperText>{helperText}</FormHelperText>
          )}
          {errorHelperText && props.error && (
            <FormHelperText sx={{ color: "var(--colorSystemColorsDanger500)" }}>
              <Image src={errorhelper} alt={""} width={16} height={16} />{" "}
              {errorHelperText}
            </FormHelperText>
          )}
        </FormControl>
      );
    };