React.js. Компонент для ввода pin-кода

08.09.2021

Теги: FrontendJavaScriptReact.jsWeb-разработкаКомпонентПрактика

Небольшой компонент для ввода pin-кода из четырех цифр. Создает четыре input-поля и отслеживает, чтобы в каждом была только одна цифра. При вводе очередной цифры смещает фокус на следующее поле. Используя клавишу Backspace, можно удалить введенное значение, при втором нажатии — сместить фокус на предыдущее поле.

Родительский компонент хранит pin-код в состоянии:

import { useState } from 'react';
import PinCodeInput from './PinCodeInput';

const initDigits = ['', '', '', ''];

export default function Form() {
    const [digits, setDigits] = useState(initDigits);

    return (
        <form>
            <PinCodeInput digits={digits} changeHandler={setDigits} />
        </form>
    );
}

Для начала отрисуем четыре input-поля и зададим для них css-стили:

import { useRef, useEffect } from 'react';

const inputStyle = {
    border: '2px solid #000',
    width: 30,
    height: 30,
    fontSize: 20,
    textAlign: 'center',
    margin: 5,
};

export default function PinCodeInput(props) {
    const {
        digits,
        changeHandler
    } = props;

    return (
        <div>
            {digits.map((digit, index) => (
                <input
                    key={index}
                    style={inputStyle}
                    value={digit}
                />
            ))}
        </div>
    );
}

Это управляемый компонент, при вводе значения в поле — нужно вызывать changeHandler:

/* .......... */
export default function PinCodeInput(props) {
    const {
        digits,
        changeHandler
    } = props;

    const handleChange = (index, newValue) => {
        const oldDigit = digits[index];
        // старую цифру из поля ввода убираем, оставляя только новую
        const newDigit = newValue.trim().replace(oldDigit, '');
        // теперь вызываем callback родителя, чтобы обовить digits
        const newDigits = [...digits]; // копия digits
        newDigits[index] = newDigit;
        changeHandler(newDigits);
    }

    return (
        <div>
            {digits.map((digit, index) => (
                <input
                    key={index}
                    style={inputStyle}
                    value={digit}
                    onChange={event => handleChange(index, event.target.value)}
                />
            ))}
        </div>
    );
}

Нам нужно, чтобы пользователь вводил только цифры:

/* .......... */
export default function PinCodeInput(props) {
    /* .......... */
    const handleChange = (index, newValue) => {
        const oldDigit = digits[index];
        // старую цифру в поле ввода убираем, оставляя только новую
        const newDigit = newValue.trim().replace(oldDigit, '');
        // если это не цифра, ничего не делаем, пока не будет цифры
        if (newDigit < '0' || newDigit > '9') return;
        // теперь вызываем callback родителя, чтобы обовить digits
        const newDigits = [...digits]; // копия digits
        newDigits[index] = newDigit;
        changeHandler(newDigits);
    }
    /* .......... */
}

После ввода цифры — смещаем фокус на следующее поле:

/* .......... */
export default function PinCodeInput(props) {
    const {
        digits,
        changeHandler
    } = props;
    const length = digits.length;
    // здесь будут ссылки на input-элементы
    const inputRefs = useRef([]);

    const handleChange = (index, newValue) => {
        const oldDigit = digits[index];
        // старую цифру в поле ввода убираем, оставляя только новую
        const newDigit = newValue.trim().replace(oldDigit, '');
        // если это не цифра, ничего не делаем, пока не будет цифры
        if (newDigit < '0' || newDigit > '9') return;
        // теперь вызываем callback родителя, чтобы обовить digits
        const newDigits = [...digits]; // копия digits
        newDigits[index] = newDigit;
        changeHandler(newDigits);
        // смещаем фокус на следующее поле для ввода следующей цифры
        if (index < length - 1) {
            inputRefs.current[index + 1].focus();
        } else { // или убираем фокус, если это было последнее поле
            inputRefs.current[index].blur();
        }
    }

    return (
        <div>
            {digits.map((digit, index) => (
                <input
                    key={index}
                    style={inputStyle}
                    value={digit}
                    onChange={event => handleChange(index, event.target.value)}
                    ref={element => inputRefs.current[index] = element}
                />
            ))}
        </div>
    );
}

Будем подсвечивать поля красным или зеленым:

/* .......... */
export default function PinCodeInput(props) {
    /* .......... */
    // поля подсвечиваем красным (ошибка) или зеленым
    useEffect(() => {
        digits.forEach((value, index) => {
            if (value.match(/^[0-9]$/)) {
                inputRefs.current[index].style.backgroundColor = '#dfd';
            } else {
                inputRefs.current[index].style.backgroundColor = '#fdd';
            }
        });
    }, [digits]);
    /* .......... */
}

При монтировании — установим фокус на первом поле:

/* .......... */
export default function PinCodeInput(props) {
    /* .......... */
    // при монтировании компонента фокус на первом поле
    useEffect(() => {
        inputRefs.current[0].focus();
    }, []);
    /* .......... */
}

Теперь добавим еще функционал, чтобы при первом нажатии клавиши Backspace удалялась введенная ранее цифра, а при повторном нажатии — фокус смещался на предыдущее поле.

import { useRef, useEffect } from 'react';

const inputStyle = {
    border: '2px solid #000',
    width: 30,
    height: 30,
    fontSize: 20,
    textAlign: 'center',
    margin: 5,
};

export default function PinCodeInput(props) {
    const {
        digits,
        changeHandler
    } = props;
    const length = digits.length;
    // здесь будут ссылки на input-элементы
    const inputRefs = useRef([]);

    // при монтировании компонента фокус на первое поле
    useEffect(() => {
        inputRefs.current[0].focus();
    }, []);

    // поля подсвечиваем красным (ошибка) или зеленым
    useEffect(() => {
        digits.forEach((value, index) => {
            if (value.match(/^[0-9]$/)) {
                inputRefs.current[index].style.backgroundColor = '#dfd';
            } else {
                inputRefs.current[index].style.backgroundColor = '#fdd';
            }
        });
    }, [digits]);

    const handleKeyDown = (index, event) => {
        if (event.key === 'Backspace') {
            event.preventDefault();
            if (digits[index].match(/^[0-9]$/)) {
                // если элемент массива digits содержит цифру, то при нажатии клавиши
                // вызываем callback-функцию родителя, чтобы записать пустую строку
                const newDigits = [...digits]; // копия digits
                newDigits[index] = '';
                changeHandler(newDigits);
            } else {
                // элемент массива digits пустой, удалять нечего — так что надо сместить
                // фокус на предыдущее поле input — при условии, что это не первое поле
                if (index > 0) inputRefs.current[index - 1].focus();
            }
        }
    }

    const handleChange = (index, newValue) => {
        const oldDigit = digits[index];
        // старую цифру в поле ввода убираем, оставляя только новую
        const newDigit = newValue.trim().replace(oldDigit, '');
        // если это не цифра, ничего не делаем, пока не будет цифры
        if (newDigit < '0' || newDigit > '9') return;
        // теперь вызываем callback родителя, чтобы обовить digits
        const newDigits = [...digits]; // копия digits
        newDigits[index] = newDigit;
        changeHandler(newDigits);
        // смещаем фокус на следующее поле для ввода следующей цифры
        if (index < length - 1) {
            inputRefs.current[index + 1].focus();
        } else { // или убираем фокус, если это было последнее поле
            inputRefs.current[index].blur();
        }
    }

    return (
        <div>
            {digits.map((digit, index) => (
                <input
                    key={index}
                    style={inputStyle}
                    value={digit}
                    onChange={event => handleChange(index, event.target.value)}
                    onKeyDown={event => handleKeyDown(index, event)}
                    ref={element => inputRefs.current[index] = element}
                />
            ))}
        </div>
    );
}

Очистка формы

Хотелось мы иметь возможность очистить pin-код. Кнопка очистки должна быть в компоненте Form. Но после этого надо установить фокус на первое поле. А для этого нам нужен доступ из Form к первому элементу <input> внутри компонента PinCodeInput.

import { useState } from 'react';
import PinCodeInput from './PinCodeInput';

const initDigits = ['', '', '', ''];

export default function Form() {
    const [digits, setDigits] = useState(initDigits);

    const clear = (event) => {
        event.preventDefault();
        setDigits(initDigits);
    }

    return (
        <form>
            <PinCodeInput digits={digits} changeHandler={setDigits} />
            <button onClick={event => clear(event)}>Очистить</button>
        </form>
    );
}

В компоненте PinCodeInput мы уже получаем ссылки на все input-элементы. И нам надо просто в компоненте Form создать переменную для хранения ссылки на первый input-элемент, передать ее через пропсы компоненту PinCodeInput и там присвоить ей значение.

import { useState, useRef } from 'react';
import PinCodeInput from './PinCodeInput';

const initDigits = ['', '', '', ''];

export default function Form() {
    const [digits, setDigits] = useState(initDigits);

    const firstInputRef = useRef();

    const clear = (event) => {
        event.preventDefault();
        setDigits(initDigits);
        firstInputRef.current.focus();
    }

    return (
        <form>
            <PinCodeInput
                digits={digits}
                changeHandler={setDigits}
                firstInputRef={firstInputRef}
            />
            <button onClick={event => clear(event)}>Очистить</button>
        </form>
    );
}
/* .......... */
export default function PinCodeInput(props) {
    const {
        digits,
        changeHandler,
        firstInputRef
    } = props;
    /* .......... */
    // при монтировании компонента фокус на первое поле
    useEffect(() => {
        inputRefs.current[0].focus();
        firstInputRef.current = inputRefs.current[0];
    }, []);
    /* .......... */
}

React еще предлагает использовать для этих целей forwardRef и useImperativeHandle. Получается запутанно, но давайте рассмотрим и этот способ. Первым делом создаем в компоненте Form переменную для хранения ссылки на первый input-элемент и передаем ее дочернему компоненту через специальный атрибут ref.

import { useState, useRef } from 'react';
import PinCodeInput from './PinCodeInput';

const initDigits = ['', '', '', ''];

export default function Form() {
    const [digits, setDigits] = useState(initDigits);

    const firstInputRef = useRef();

    const clear = (event) => {
        event.preventDefault();
        setDigits(initDigits);
        firstInputRef.current.focus();
    }

    return (
        <form>
            <PinCodeInput
                digits={digits}
                changeHandler={setDigits}
                ref={firstInputRef}
            />
            <button onClick={event => clear(event)}>Очистить</button>
        </form>
    );
}

Дочерний компонент PinCodeInput нужно обернуть в forwardRef:

import { useRef, useEffect, useImperativeHandle, forwardRef } from 'react';
/* .......... */
function PinCodeInput(props, refer) {
    const {
        digits,
        changeHandler
    } = props;
    const length = digits.length;
    // здесь будут ссылки на input-элементы
    const inputRefs = useRef([]);

    useImperativeHandle(refer, () => ({
        focus: () => inputRefs.current[0].focus()
    }));

    // при монтировании компонента фокус на первое поле
    useEffect(() => {
        inputRefs.current[0].focus();
    }, [])
    /* .......... */
}

export default forwardRef(PinCodeInput);

Хук useImperativeHandle в качестве первого аргумента принимает объект refer, который мы создали в компоненте Form и передали в PinCodeInput через специальный атрибут ref. В качестве второго аргумента хук принимает функцию, которая должна вернуть объект, который станет значением свойства current этого объекта. В свойство current (которое является объектом) мы записываем одно-единственное свойство focus — нам просто больше ничего не нужно.

В результате всех этих манипуляций переменная firstInputRef, которую мы определили в компоненте Form, будет иметь вид:

{
    current: {
        focus: () => inputRefs.current[0].focus()
    }
}

В свойство current мы могли бы записать и другие свойства, например style, чтобы изменять css-стили первого input-элемента:

useImperativeHandle(refer, () => ({
    focus: () => inputRefs.current[0].focus(),
    style: inputRefs.current[0].style
}));
{
    current: {
        focus: () => inputRefs.current[0].focus(),
        style: CSSStyleDeclaration {...}
    }
}

Поиск: JavaScript • React.js • Web-разработка • Frontend • Компонент • Практика

Каталог оборудования
Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.
Производители
Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.
Функциональные группы
Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.