Как соединить react-input-mask и antd или какие есть альтернативы добавления маски к antd?

Всем доброго времени суток. Я много информации уже изучил по поводу соединения react-input-mask и antd.

использовал:

npm install react-input-mask@latest
npm i --save-dev @types/react-input-mask

Я пробовал все примеры от сюда https://github.com/sanniassin/react-input-mask/issues/139 Изучил https://github.com/ant-design/ant-design/issues/10580 Также просто все что мог находил на форумах. На них ответы были бесполезны.

Также пробовал npm install antd-mask-input --save Но видимо из-за того, что библиотека уже не поддерживается - то не применяется измененный primaryColor для компонента-маски и не работает Compact для компонента-маски (и это только что я заметил, возможно больше еще нерабочих моментов).

Может у кого есть на текущий момент рабочее решение, чтобы хорошо стыковалось с antd. Поделитесь информацией, может ссылку на обсуждение или какие-то варианты добавления маски к antd или все-таки самому писать велосипед?


Ответы (2 шт):

Автор решения: xinthe

Хочу поделиться успехами соединения react-input-mask и antd (хотя они не прям успешные)

Перечислю, что я сделал:

Скачал необходимые пакеты

npm install react-input-mask@latest
npm i --save-dev @types/react-input-mask

Пометка: на данный момент последней стабильной версией react-input-mask является версия 2.0.4. Я делал именно с ней. Есть еще версия 3.0.0-alpha.2, с ней мой код уже не работает.

Далее я сделал базовый компонент MaskedInput:

import { forwardRef, ReactNode } from 'react';
import { Input, InputProps } from 'antd';
import { Props } from 'react-input-mask';
import InputMask from 'react-input-mask';

type AllowedValue = string | number | readonly string[] | undefined;
type StrictOmit<T, K extends keyof T> = Pick<T, Exclude<keyof T, K>>;

export type MaskedInputProps = StrictOmit<InputProps, 'size' | 'prefix' | 'value' | 'onChange'> &
    Props & {
        mask: string;
        size?: InputProps['size'];
        prefix?: ReactNode;
        value: AllowedValue;
        onChange: (event: React.ChangeEvent<HTMLInputElement>) => void;
    };

export const MaskedInput = forwardRef<HTMLInputElement, MaskedInputProps>(
    ({ mask, size, prefix, ...rest }, ref) => {
        return (
            <InputMask mask={mask} {...rest}>
                {(inputProps) => <Input {...inputProps} size={size} prefix={prefix} ref={ref} />}
            </InputMask>
        );
    },
);

Пометка: я сделал value и onChange обязательными, потому что без их определения маска работала совсем некорректно. Причину такого поведения я не выяснил.

Я планировал делать обертки конкретных масок над MaskedInput. Вот пример с номером телефона:

import { FC, useState } from 'react';
import { StrictOmit } from '@/shared/utils';
import { MaskedInput, MaskedInputProps } from './MaskedInput';

type StrictOmit<T, K extends keyof T> = Pick<T, Exclude<keyof T, K>>;
export interface PhoneInputProps
    extends StrictOmit<MaskedInputProps, 'mask' | 'placeholder' | 'value' | 'onChange'> {
    onChange?: (event: React.ChangeEvent<HTMLInputElement>) => void;
}

export const PhoneInput: FC<PhoneInputProps> = ({
    defaultValue,
    onChange,
    ...maskedInputProps
}) => {
    const [value, setvalue] = useState(defaultValue);

    return (
        <MaskedInput
            {...maskedInputProps}
            mask="+7 (999) 999-99-99"
            placeholder="+7 (___) ___-__-__"
            value={value}
            onChange={(event) => {
                setvalue(event.target.value);
                onChange?.(event);
            }}
        />
    );
};

В целом это работает, НО остались проблемы:

  1. при удалении символа, когда каретка стоит перед символом маски, происходит перемещение каретки назад на символ маски и + на символ значения. Например введено значение
+7 (123) 456-78-97

и при удержании backspace останется

+7 (368) ___-__-__

то есть как раз цифры перед символами маски: "3)", "6-" и "8-";

  1. когда маска уже заполнена полностью и если все равно еще набирать цифры, то визуально ничего не будет происходить, но в значении Input вводится лишнее число в конце и получается вместо +7 (123) 456-78-97 на одну цифру больше +7 (123) 456-78-977. Это и забирается Form из antd (неправильное значение).

С этими проблемами мне не удалось справиться. Если у кого-то получится их решить, то я думаю этим можно будет пользоваться.

Далее я перешел все-таки на antd-mask-input и попытался решить те проблемы, которые написал в вопросе и дополнить его еще функционалом. Для работы с antd-mask-input я использовал следующее:

npm install antd-mask-input
npm antd-style

Пометка: "antd-mask-input" на данный момент версии "2.0.7". "antd-style" на данный момент версии "3.7.1"

Сделал свою обертку над MaskedInput из antd-mask-input, которая решает для меня следующие проблемы:

  1. Применяет измененные стили в конфигурации antd (с помощью useStylesMaskInput);
  2. Начинает корректно работать с Compact из antd (с помощью useStylesMaskInput);
  3. Если остается только маска без полезных данных, то возвращается пустое значение в форму (с помощью handleChange в котором обнуляется возвращаемое значение);
  4. Добавил onChangeUnmaskedValue для того, чтобы читать значение без маски извне. Также сделал параметр onChangeUnMask который для обработчика onChange будет возвращать для формы значение без маски.

Код компонента:

import { FC } from 'react';
import MaskedInput from 'antd-mask-input';
import { MaskedInputProps } from 'antd-mask-input/build/main/lib/MaskedInput';
import { useStylesMaskInput } from './useStylesMaskInput';

type OnChangeEvent = Parameters<NonNullable<MaskedInputProps['onChange']>>[0];

interface MaskInputProps extends MaskedInputProps {
    onChangeUnmaskedValue?: (value: string) => void;
    onChangeUnMask?: boolean;
}

export const MaskInput: FC<MaskInputProps> = ({
    onChangeUnmaskedValue,
    onChangeUnMask,
    onChange,
    className,
    ...maskedInputProps
}) => {
    const { styles, cx } = useStylesMaskInput();

    
    const handleChange = (event: OnChangeEvent) => {
        if (event.unmaskedValue.length === 0) {
            const clearedEvent: OnChangeEvent = {
                ...event,
                target: {
                    ...event.target,
                    value: '',
                },
                maskedValue: '',
                unmaskedValue: '',
            };

            onChange?.(clearedEvent);
            onChangeUnmaskedValue?.('');
        } else {
            if (onChangeUnMask) {
                const updateEvent: OnChangeEvent = {
                    ...event,
                    target: {
                        ...event.target,
                        value: event.unmaskedValue,
                    },
                };
                onChange?.(updateEvent);
            } else {
                onChange?.(event);
            }

            onChangeUnmaskedValue?.(event.unmaskedValue);
        }
    };

    return (
        <MaskedInput
            {...maskedInputProps}
            onChange={handleChange}
            className={cx(styles.maskInput, className)}
        />
    );
};

Код хука useStylesMaskInput:

import { createStyles } from 'antd-style';

export const useStylesMaskInput = createStyles(({ token }) => ({
    maskInput: {
        borderRadius: token.borderRadiusLG,
        border: `${token.lineWidth}px ${token.lineType} ${token.colorBorder}`,
        overflow: 'hidden',
        transition: `border-color ${token.motionDurationMid} ${token.motionEaseInOut}`,

        '&:hover': {
            borderColor: token.colorPrimaryHover,
        },

        '&:focus-within': {
            borderColor: token.colorPrimary,
            boxShadow: `0 0 0 ${token.controlOutlineWidth}px ${token.controlOutline}`,
            transition: `box-shadow ${token.motionDurationMid} ${token.motionEaseInOut}`,
        },

        '.ant-space-compact &': {
            '&:not(:last-child)': {
                borderTopRightRadius: 0,
                borderBottomRightRadius: 0,
                marginRight: -1,
            },
            '&:first-child:not(:last-child)': {
                borderTopRightRadius: 0,
                borderBottomRightRadius: 0,
            },
            '&:last-child': {
                borderTopRightRadius: token.borderRadiusLG,
                borderBottomRightRadius: token.borderRadiusLG,
            },

            '&:hover': {
                zIndex: 2,
            },

            '&:focus': {
                zIndex: 2,
            },
        },
    },
}));

Также узнал про настройку lazy, которая мне видится полезной. Под капотом antd-mask-input использует библиотеку imask (можно обратиться к документации, чтобы лучше понимать как можно настраивать MaskedInput из antd-mask-input или обертку MaskInput). В библиотеке есть параметр lazy, который скрывает маску, если она пустая. Для использования в компонентах - этот параметр можно задать через maskOptions:

<MaskedInput
    maskOptions={{ lazy: true }}
/>

Еще мне не нравился такой момент: при параметре lazy=false, когда поле находится не в фокусе и в поле не введены полезные значения (только маска), создается ощущение, что как будто бы поле не пустое. Для меня более предпочтительно выглядит, если полезных данных нет в поле, то и не стоит отображать маску (лучше отобразить placeHolder). Такого поведения тоже удалось достичь, немного изменив компонент:

import { FC, useEffect, useRef, useState } from 'react';
import MaskedInput from 'antd-mask-input';
import { MaskedInputProps } from 'antd-mask-input/build/main/lib/MaskedInput';
import { useStylesMaskInput } from './useStylesMaskInput';

type OnChangeEvent = Parameters<NonNullable<MaskedInputProps['onChange']>>[0];

interface MaskInputProps extends MaskedInputProps {
    onChangeUnmaskedValue?: (value: string) => void;
    onChangeUnMask?: boolean;
}

export const MaskInput: FC<MaskInputProps> = ({
    onChangeUnmaskedValue,
    onChangeUnMask,
    mask,
    onChange,
    onBlur,
    onFocus,
    className,
    ...maskedInputProps
}) => {
    const { styles, cx } = useStylesMaskInput();

    const isLazy = maskedInputProps.maskOptions?.lazy ?? true;
    const initialMask = useRef(mask);
    const [currentMask, setCurrentMask] = useState(mask);
    const unmaskedValueRef = useRef<string | undefined>(undefined);

    useEffect(() => {
        unmaskedValueRef.current = maskedInputProps.value || maskedInputProps.defaultValue;
        if (!isLazy && !unmaskedValueRef.current) {
            setCurrentMask('');
        }
    }, []);

    const handleChange = (event: OnChangeEvent) => {
        unmaskedValueRef.current = event.unmaskedValue;

        if (event.unmaskedValue.length === 0) {
            const clearedEvent: OnChangeEvent = {
                ...event,
                target: {
                    ...event.target,
                    value: '',
                },
                maskedValue: '',
                unmaskedValue: '',
            };

            onChange?.(clearedEvent);
            onChangeUnmaskedValue?.('');
        } else {
            if (onChangeUnMask) {
                const updateEvent: OnChangeEvent = {
                    ...event,
                    target: {
                        ...event.target,
                        value: event.unmaskedValue,
                    },
                };
                onChange?.(updateEvent);
            } else {
                onChange?.(event);
            }

            onChangeUnmaskedValue?.(event.unmaskedValue);
        }
    };

    const handleBlur = (event: React.FocusEvent<HTMLInputElement>) => {
        if (!isLazy && !unmaskedValueRef.current) {
            setCurrentMask('');
        }

        onBlur?.(event);
    };

    const handleFocus = (event: React.FocusEvent<HTMLInputElement>) => {
        if (!isLazy && !unmaskedValueRef.current) {
            setCurrentMask(initialMask.current);
        }

        onFocus?.(event);
    };

    return (
        <MaskedInput
            {...maskedInputProps}
            mask={currentMask}
            onChange={handleChange}
            onBlur={handleBlur}
            onFocus={handleFocus}
            className={cx(styles.maskInput, className)}
        />
    );
};

Буду рад вашей обратной связи по поводу багов или возможных улучшений этого компонента-обертки

→ Ссылка
Автор решения: Станислав Усанов

что то сложно у тебя получается :)

Вот моя реализация на скорую руку, нужно допинать не без косяков

'use client'
import { Input, InputProps, InputRef } from 'antd'
import IMask, { InputMask } from 'imask'
import React, { useEffect, useRef } from 'react'

export interface MaskInputProps extends InputProps {
  mask: string
}

export const MaskInput: React.FC<MaskInputProps> = (props) => {
  const { mask, onChange, ...anyProps } = props
  const inputRef = useRef<InputRef>(null)
  const maskRef = useRef<InputMask>(null)

  useEffect(() => {
    if (inputRef.current?.input) {
      const _mask = IMask(inputRef.current.input, { mask })
      maskRef.current = _mask

      return () => {
        _mask.destroy()
      }
    }
  }, [mask])

  const handleChange: InputProps['onChange'] = (event) => {
    if (maskRef.current) {
      maskRef.current.updateValue()
    }

    // console.log('@@ maskRef.current', maskRef.current?.unmaskedValue)

    onChange?.(event)
  }

  return <Input {...anyProps} ref={inputRef} onChange={handleChange} />
}

→ Ссылка