lucasbasquerotto / react-masked-input

MIT License
28 stars 0 forks source link

Currency mask input #2

Closed jdnichollsc closed 1 year ago

jdnichollsc commented 1 year ago

Hello there, thanks for this great project!

I was wondering if you're planning to implement a mask for currency prices?

Thanks for your help!

lucasbasquerotto commented 1 year ago

@jdnichollsc Hi! You can create a mask generator function that do that, like:

export const getCurrencyMaskGenerator = ({
    prefix = '',
    thousandSeparator = '',
    centsSeparator = '',
}: {
    prefix?: string;
    thousandSeparator?: string;
    centsSeparator?: string;
}): MaskGenerator => {
    const getRawValue = (value: string): string => {
        const valNoPrefix = value?.startsWith(prefix)
            ? value?.substring(prefix.length)
            : value;

        const valNoCents = centsSeparator
            ? valNoPrefix?.replaceAll(centsSeparator, '')
            : valNoPrefix;

        const valDigits = thousandSeparator
            ? valNoCents?.replaceAll(thousandSeparator, '')
            : valNoCents;

        return valDigits ?? '';
    };

    return {
        rules: DEFAULT_MASK_RULES,
        generateMask: (value) => {
            const rawVal = getRawValue(value);
            const len = rawVal.length;
            const lenCents = centsSeparator ? 2 : 0;
            const lenNoCents = Math.max(len - lenCents, 0);

            const mask =
                prefix +
                '9'.repeat(lenNoCents % 3) +
                (lenNoCents % 3 > 0 && lenNoCents >= 3 ? thousandSeparator : '') +
                (lenNoCents >= 3
                    ? Array(Math.trunc(lenNoCents / 3))
                            .fill('999')
                            .join(thousandSeparator)
                    : '') +
                centsSeparator +
                '9'.repeat(lenCents);

            return mask;
        },
        transform: (value) => {
            const valDigits = getRawValue(value);
            const rawVal = valDigits?.replace(/^0+/, '');
            const len = rawVal.length;
            const prefixToUse = value?.startsWith(prefix) ? prefix : '';

            if (centsSeparator && len < 3) {
                return (
                    prefixToUse + '0' + centsSeparator + '0'.repeat(2 - len) + rawVal
                );
            } else if (valDigits?.length !== rawVal?.length) {
                const initial: {
                    current: string;
                    parts: string[];
                } = { current: '', parts: [] };

                const { parts: thousandsParts } = rawVal
                    .substring(0, len - 2)
                    .split('')
                    .reverse()
                    .reduce(({ current, parts }, char, idx, arr) => {
                        const newCurrent = char + current;

                        if (idx === arr?.length - 1 || (idx + 1) % 3 === 0) {
                            return {
                                current: '',
                                parts: [newCurrent, ...parts],
                            };
                        }

                        return {
                            current: newCurrent,
                            parts,
                        };
                    }, initial);

                const valNoCents = thousandsParts.join(thousandSeparator);

                const newValue =
                    prefixToUse + valNoCents + centsSeparator + rawVal.substring(len - 2);

                return newValue;
            }

            return value;
        },
    };
};

There were some issues in specific cases in the caret position, when you delete some chars, or add in the middle.

I fixed those issues and released a new version (1.1.12). I also took the opportunity to add the function above as an utility and you can import it from the package, instead of duplicating it.

You can use it as:

import { MaskedInput, getCurrencyMaskGenerator } from 'react-hook-mask';

const maskGenerator = getCurrencyMaskGenerator({
    prefix: 'US$ ',
    thousandSeparator: ' ',
    centsSeparator: '.',
});

const CurrencyMask = () => {
    const [value, setValue] = React.useState('');

    return (
        <div>
            <MaskedInput
                type="tel"
                maskGenerator={maskGenerator}
                value={value}
                onChange={setValue}
            />
            <div>
                {'Value (no mask): '}
                {value}
            </div>
        </div>
    );
};

You can define different currencies (like 'US$ ', 'R$ ', '€ '), using the prefix, or leave it empty/undefined, if you want only the numeric value.

You can use different separators for thousands, like ' ', '.', or leave it empty/undefined, if you don't want separators.

You can use different symbols for the cents (decimal) separators, like '.', ',', or leave it empty/undefined, if the currency has no cents (like yen and won).

In the example above, for an input of 123456789, you would see US$ 1 234 567.89 in the input.

I haven't added how to use it yet in the README because I didn't do extensive tests with it, but from what I saw, it should work fine. You can give a feedback here if it worked for you.

You can see the released version at https://www.npmjs.com/package/react-hook-mask?activeTab=versions

I will close this issue, but if you find an issue you can reply here or open a new one.

jdnichollsc commented 1 year ago

@lucasbasquerotto Hello mate, thanks for your amazing support!

I'm creating a custom hook but I'm testing some issues with the cursor while user is typing before the decimals:

import { useCallback } from 'react';
import type { MaskGenerator } from 'react-hook-mask';
import { useRefMask, DEFAULT_MASK_RULES } from 'react-hook-mask';

import { getCurrencyMaskGenerator } from './utils/mask.util';

const dateMaskGenerator: MaskGenerator = {
  rules: DEFAULT_MASK_RULES,
  generateMask: () => '99/99/9999',
};

export type InputType = 'currency';

export const getMaskGenerator = (type: InputType): MaskGenerator => {
  switch (type) {
    case 'currency':
      return getCurrencyMaskGenerator({
        prefix: '$ ',
        thousandSeparator: ',',
        centsSeparator: '.',
      });
    default:
      return null;
  }
}

export interface UseMaskedInputProps {
  type: InputType;
  ref?: React.ForwardedRef<HTMLInputElement>;
}

export const useMaskedInput = ({ type, ref: outerRef }: UseMaskedInputProps) => {
  const getCursorPosition = useCallback(
    (el: HTMLInputElement | undefined) => {
    const cursorPosition = el?.selectionStart ?? 0;
    console.log('GET CURSOR POSITION', cursorPosition)
    return cursorPosition;
    },
    [],
  );

  const setCursorPosition = useCallback(
  (cursorPosition: number, el: HTMLInputElement | undefined) => {
      console.log('SET CURSOR POSITION', cursorPosition)
      if (el) {
        el.selectionStart = cursorPosition;
        el.selectionEnd = cursorPosition;
      }
    },
    [],
  );

  const onChange = (data) => {
    console.log('On change', data)
  };

  const { displayValue, ref, setDisplayValue } = useRefMask({
    maskGenerator: getMaskGenerator(type),
    getCursorPosition,
    setCursorPosition,
    onChange,
    keepMask: true,
    ref: outerRef,
  });

  return { value: displayValue, onChange: setDisplayValue, ref };
}

I'm still testing if I need to change the logic from setCursorPosition, thanks for your help!

jdnichollsc commented 1 year ago

Oh I was trying to pass the value prop to the useRefMask hook and I got thousands of warnings from console, e.g:

react-dom.development.js:86 Warning: Maximum update depth exceeded. This can happen when a component calls setState inside useEffect, but useEffect either doesn't have a dependency array, or one of the dependencies changes on every render.

what do you think? :thinking:

jdnichollsc commented 1 year ago

There were some issues in specific cases in the caret position, when you delete some chars, or add in the middle.

Please try typing the number 1234567.00 and let me know if that works for you <3

lucasbasquerotto commented 1 year ago

Hi, @jdnichollsc

I couldn't simulate the error Maximum update depth exceeded, but if I have to guess, it might be because the mask generator is created again in every render. I advise to memoise it:

// in the hook
const maskGenerator = React.useMemo(() => getMaskGenerator(type), [type]);

Then I used it as

const CurrencyMaskedInputTest = () => {
    const { value, onChange, ref } = useMaskedInput({ type: 'currency' });

    return (
        <div>
            <input
                ref={ref}
                value={value}
                onChange={(e) => onChange(e?.target?.value)}
            />
            <div className="info">Value:</div>
            <div className="info">{value}</div>
        </div>
    );
};

Regarding the issue of the cursor position, I was able to simulate typing 1234567.00, starting at the left of the cents separator.

I will see if the fix won't take much time, otherwise it could take a bit longer for me to fix and release a new version. I will take a look tough.

From what I see it only happens when typing in the start or middle of the input, but usually the user will type at the end.

jdnichollsc commented 1 year ago

Hi, @jdnichollsc

I couldn't simulate the error Maximum update depth exceeded, but if I have to guess, it might be because the mask generator is created again in every render. I advise to memoise it:

// in the hook
const maskGenerator = React.useMemo(() => getMaskGenerator(type), [type]);

Oh I tried that but I'm still getting that error when I pass the value to the useRefMask hook:

import { useCallback, useMemo } from 'react';
import { getCurrencyMaskGenerator, MaskGenerator, useRefMask, DEFAULT_MASK_RULES } from 'react-hook-mask';

import { MaskInputType } from './utils/mask.util';

const dateMaskGenerator: MaskGenerator = {
  rules: DEFAULT_MASK_RULES,
  generateMask: () => '99/99/9999',
};

export const getMaskGenerator = (type: MaskInputType): MaskGenerator => {
  switch (type) {
    case 'date':
      return dateMaskGenerator;
    case 'currency':
      return getCurrencyMaskGenerator({
        prefix: '',
        thousandSeparator: ',',
        centsSeparator: '.',
      });
    default:
      return null;
  }
}

export interface UseMaskedInputProps {
  type: MaskInputType;
  value: string;
  onChange: (val: string) => void;
  ref?: React.ForwardedRef<HTMLInputElement>;
}

export const useMaskedInput = ({ type, value, onChange, ref: outerRef }: UseMaskedInputProps) => {
  const getCursorPosition = useCallback(
    (el: HTMLInputElement | undefined) => {
      const cursorPosition = el?.selectionStart ?? 0;
      return cursorPosition;
    },
    [],
  );

  const setCursorPosition = useCallback(
    (cursorPosition: number, el: HTMLInputElement | undefined) => {
      if (el) {
        el.selectionStart = cursorPosition;
        el.selectionEnd = cursorPosition;
      }
    },
    [],
  );

  const maskGenerator = useMemo(() => getMaskGenerator(type), [type]);

  const { displayValue, ref, setDisplayValue } = useRefMask({
    value,
    maskGenerator,
    getCursorPosition,
    setCursorPosition,
    onChange,
    keepMask: false,
    ref: outerRef,
  });

  return { value: displayValue, onChange: setDisplayValue, ref };
}

I'm passing that prop in order to have an initial value: "0.00"

Thanks for your quick response! <3

jdnichollsc commented 1 year ago

@lucasbasquerotto hey mate, I tried updating the below configuration :

const setCursorPosition = useCallback(
  (cursorPosition: number, el: HTMLInputElement | undefined) => {
    if (el) {
      if (type === 'currency') {
        const decimalPos = el.value.indexOf('.');
        if (cursorPosition < decimalPos) {
          el.setSelectionRange(decimalPos, decimalPos);
          return;
        }
      }
      el.selectionStart = cursorPosition;
      el.selectionEnd = cursorPosition;
    }
  },
  [type],
);

const maskGenerator = useMemo(() => getMaskGenerator(type), [type]);

const formattedValue = useMemo(() => {
  if (type === 'currency' && typeof value === 'number') {
    return USDFormatter.format(value);
  } else if (type === 'date' && isDate(value)) {
    return DateFormatter.format(value);
  }
  return value + '';
} , [type, value]);

const { displayValue, ref, setDisplayValue } = useRefMask({
  value: formattedValue,
  maskGenerator,
  getCursorPosition,
  setCursorPosition,
  onChange,
  keepMask: true,
  ref: outerRef,
});

But I'm still getting a lot of logs and a wrong behavior while deleting numbers, please try my implementation here https://stackblitz.com/edit/react-ts-mdrwdn?file=App.tsx,useMaskedInput.ts,index.tsx

Let me know how can I help! BTW, can you reopen this issue? <3

lucasbasquerotto commented 1 year ago

@jdnichollsc I strongly advise to not format the value that will be passed to the hook, because the value will be formatted already, and a double formatting could cause issues, like losing part of the value, as you can see in the stackblitz, or possibly infinite loops that could cause Maximum update depth exceeded errors (for example, if after this package changed the value, and is expecting this value, you format that value and pass it, and this package may end up formatting again, which could cause a loop if the chain of formats does not converge in a specific value).

In the code above, couldn't you use:

const formattedValue = useMemo(() => {
    return value ? ('' + value) : '';
}, [value]);

?

lucasbasquerotto commented 1 year ago

@jdnichollsc I released a new version (1.1.15) with a fix regarding the cursor position.

You can see a currency mask component demo at https://lucasbasquerotto.github.io/react-masked-input