Today PM raised a requirement to implement a 6 digit OTP input box, each input box is a separate grid, so I opened vscode…

The initial idea was to use a hidden input to store the actual react controlled data, and then use six separate inputs or divs to display six grids

The general structure is as follows

import React from "react";

const PIN_LENGTH = 6;

export default function InputOTP() {
  return (
    <div>
      <input type="number" pattern="\d*" maxLength={PIN_LENGTH} />
      {Array.from({ length: PIN_LENGTH }).map((_, index) => {
        return <input key={index} />;
      })}
    </div>
  );
}
Copy the code

When only type=’number’ and pattern= ‘\d{6}’ are set, the keyboard will still display symbols. Only the numeric keyboard will be displayed if pattern= ‘\d*’ is set

Next we deal with styles and data by adding some class names to the elements and using value to store the input data:

export default function InputOTP() {
  const [value, setValue] = React.useState("");
  
  return (
    <div className={"container"} >
      <input
        className={"hiddenInput"}
        type="number"
        pattern="\d*"
        maxLength={PIN_LENGTH}
      />
      {Array.from({ length: PIN_LENGTH }).map((_, index) => {
        const focus =
          index === value.length ||
          (index === PIN_LENGTH - 1 && value.length === PIN_LENGTH);
        return (
          <input
            className={`pinInputThe ${focus ? "fucos" :""} `}key={index}
            value={value[index]| | ""}readOnly={true}
          />
        );
      })}
    </div>
  );
}
Copy the code

The style is as follows:

.container {
  display: flex;
  width: 100%;
  flex-wrap: nowrap;
  justify-content: center;
}
.hiddenInput {
  width: 0;
  height: 0;
  outline: "none";
  padding: 0;
  border-width: 0;
  box-shadow: "none";
  position: "absolute";
}

.pinInput {
  box-sizing: border-box;
  padding: 0;
  outline: none;
  background-color: transparent;
  width: 36px;
  height: 36px;
  margin: 10px 10px 20px;
  text-align: center;
  border: 1px solid rgb(189.189.189);
  border-radius: 3px;
  font-size: 25px;
  font-weight: 500;
}

.fucos {
  border-color: orangered;
  border-width: 2px;
}
Copy the code

You can see the initial results:

But now we can’t enter anything because the readOnly for input has been set to true. We now have six small input click events to process. When clicked we focus the hidden input and then listen for the hidden input onChange event to store the changed value to value. This is the controlled component. You also need to deal with special keys like left and right arrows and Spaces to clear their default behavior.

The code for the final lite version is as follows:

const PIN_LENGTH = 6;
const KEYCODE = Object.freeze({
  LEFT_ARROW: 37.RIGHT_ARROW: 39.END: 35.HOME: 36.SPACE: 32});export default function InputOTP() {
  const [value, setValue] = React.useState("");
  const inputRef = React.useRef();

  function handleClick(e) {
    e.preventDefault();
    if(inputRef.current) { inputRef.current.focus(); }}function handleChange(e) {
    const val = e.target.value || "";
    setValue(val);
  }

  // Handle some special keyboard keys, clear the default behavior
  function handleOnKeyDown(e) {
    switch (e.keyCode) {
      case KEYCODE.LEFT_ARROW:
      case KEYCODE.RIGHT_ARROW:
      case KEYCODE.HOME:
      case KEYCODE.END:
      case KEYCODE.SPACE:
        e.preventDefault();
        break;
      default:
        break; }}return (
    <div className={"container"} >
      <input
        ref={inputRef}
        className={"hiddenInput"}
        type="number"
        pattern="\d*"
        onChange={handleChange}
        onKeyDown={handleOnKeyDown}
        maxLength={PIN_LENGTH}
      />
      {Array.from({ length: PIN_LENGTH }).map((_, index) => {
        const focus =
          index === value.length ||
          (index === PIN_LENGTH - 1 && value.length === PIN_LENGTH);
        return (
          <input
            className={`pinInputThe ${focus ? "fucos" :""} `}key={index}
            value={value[index]| | ""}onClick={handleClick}
            readOnly={true}
          />
        );
      })}
    </div>
  );
}
Copy the code

The effect is as follows:

I thought it was done, but who knows when testing, PM said that for the convenience of users, it can implement the paste function? Who let us be humble cuttoson, dare not resist, can only silently open vscode again…

If we want to implement the copy-paste function, it seems that the previous design will not work, because the outer six small input is only used to display the data, it is read-only, the real control of the data is the hidden input, but we can not pass the small input long press event to the hidden input. Setting readOnly for small input to false doesn’t seem to work either…

So why do we need to use hidden inputs for data control? We can use six inputs for data control, and the key question is when to focus on each input. We store an index in the component that represents the input subscript of the current focus, and all subsequent operations are based on this index

The final code is as follows:

export default function InputOTP() {
  const [value, setValue] = React.useState("");
  // Store 6 input references
  const inputsRef = React.useRef([]);
  // The input subscript of the current focus
  const curFocusIndexRef = React.useRef(0);

  // Verify that the value is valid only if a number exists
  const isInputValueValid = React.useCallback((value) = > {
    return /^\d+$/.test(value); } []);// Focus the input of the specified subscript
  const focusInput = React.useCallback((i) = > {
    const inputs = inputsRef.current;
    if (i >= inputs.length) return;
    const input = inputs[i];
    if(! input)return; input.focus(); curFocusIndexRef.current = i; } []);// Focus on the last input
  const focusNextInput = React.useCallback(() = > {
    const curFoncusIndex = curFocusIndexRef.current;
    const nextIndex =
      curFoncusIndex + 1 >= pinLength ? pinLength - 1 : curFoncusIndex + 1;
    focusInput(nextIndex);
  }, [focusInput]);

  // Focus on the previous input
  const focusPrevInput = React.useCallback(() = > {
    const curFoncusIndex = curFocusIndexRef.current;
    let prevIndex;
    if (curFoncusIndex === pinLength - 1 && value.length === pinLength) {
      prevIndex = pinLength - 1;
    } else {
      prevIndex = curFoncusIndex - 1< =0 ? 0 : curFoncusIndex - 1;
    }
    focusInput(prevIndex);
  }, [focusInput, value]);

  // Handle the delete button
  const handleOnDelete = React.useCallback(() = > {
    const curIndex = curFocusIndexRef.current;
    if (curIndex === 0) {
      if(! value)return;
      setValue("");
    } else if (curIndex === pinLength - 1 && value.length === pinLength) {
      setValue(value.slice(0, curIndex));
    } else {
      setValue(value.slice(0, value.length - 1));
    }
    focusPrevInput();
  }, [focusPrevInput, value]);

  const handleOnKeyDown = React.useCallback(
    (e) = > {
      switch (e.keyCode) {
        case KEYCODE.LEFT_ARROW:
        case KEYCODE.RIGHT_ARROW:
        case KEYCODE.HOME:
        case KEYCODE.END:
        case KEYCODE.SPACE:
          e.preventDefault();
          break;
        // When the delete button is clicked
        case KEYCODE.BACK_SPACE:
          handleOnDelete();
          break;
        default:
          break;
      }
    },
    [handleOnDelete]
  );

  // When clicking input, refocus the current input to pop up the keyboard
  const handleClick = React.useCallback(() = > {
    focusInput(curFocusIndexRef.current);
  }, [focusInput]);

  const handleChange = React.useCallback(
    (e) = > {
      const val = e.target.value || "";
      if(! isInputValueValid(val))return;
      if (val.length === 1) {
        focusNextInput();
        setValue(`${value}${val}`);
      }
    },
    [focusNextInput, isInputValueValid, value]
  );

  const handlePaste = React.useCallback(
    (e) = > {
      // Be sure to clear the default behavior
      e.preventDefault();
      const val = e.clipboardData.getData("text/plain").slice(0, pinLength);
      if(! isInputValueValid(val))return;
      const len = val.length;
      const index = len === pinLength ? pinLength - 1 : len;
      // If there is input before, it can be overwritten directly, or it can be implemented without overwriting
      setValue(val);
      focusInput(index);
    },
    [focusInput, isInputValueValid]
  );

  return (
    <div className={"container"} >
      {Array.from({ length: pinLength }).map((_, index) => {
        const focus = index === curFocusIndexRef.current;
        return (
          <input
            key={index}
            ref={(ref)= > (inputsRef.current[index] = ref)}
            className={`pinInput ${focus ? "focus" : ""}`}
            maxLength={1}
            type="number"
            pattern="\d*"
            autoComplete="false"
            value={value[index] || ""}
            onClick={handleClick}
            onChange={handleChange}
            onPaste={handlePaste}
            onKeyDown={handleOnKeyDown}
          />
        );
      })}
    </div>
  );
}
Copy the code

One small detail to note here is that since the input is not read-only, there will be a cursor when typing, and since the cursor is not required for this requirement, we just need to add some CSS to the input

color: transparent;
caret-color: transparent;
text-shadow: 0 0 0 # 000;
Copy the code

Here’s the result: