Skip to content

Instantly share code, notes, and snippets.

@Sutil
Last active April 16, 2025 17:55
Show Gist options
  • Save Sutil/5285f2e5a912dcf14fc23393dac97fed to your computer and use it in GitHub Desktop.
Save Sutil/5285f2e5a912dcf14fc23393dac97fed to your computer and use it in GitHub Desktop.
Shandcn UI Money Mask Input - NextJS.
"use client";
import { zodResolver } from "@hookform/resolvers/zod";
import { useForm } from "react-hook-form";
import MoneyInput from "src/components/custom/money-input";
import { Button } from "src/components/ui/button";
import { Form } from "src/components/ui/form";
import * as z from "zod";
const schema = z.object({
value: z.coerce.number().min(0.01, "Required"),
});
export default function PlanForm() {
const form = useForm<z.infer<typeof schema>>({
resolver: zodResolver(schema),
defaultValues: {
value: 0,
},
mode: "onTouched",
});
function onSubmit(values: z.infer<typeof schema>) {
// handle submit
}
return (
<Form {...form}>
<form
className="flex flex-col gap-8"
onSubmit={form.handleSubmit(onSubmit)}
>
<MoneyInput
form={form}
label="Valor"
name="value"
placeholder="Valor do plano"
/>
<Button type="submit" disabled={!form.formState.isValid}>
Submit
</Button>
</form>
</Form>
);
}
"use client";
import { useReducer } from "react";
import {
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "../ui/form"; // Shadcn UI import
import { Input } from "../ui/input"; // Shandcn UI Input
import { UseFormReturn } from "react-hook-form";
type TextInputProps = {
form: UseFormReturn<any>;
name: string;
label: string;
placeholder: string;
};
// Brazilian currency config
const moneyFormatter = Intl.NumberFormat("pt-BR", {
currency: "BRL",
currencyDisplay: "symbol",
currencySign: "standard",
style: "currency",
minimumFractionDigits: 2,
maximumFractionDigits: 2,
});
export default function MoneyInput(props: TextInputProps) {
const initialValue = props.form.getValues()[props.name]
? moneyFormatter.format(props.form.getValues()[props.name])
: "";
const [value, setValue] = useReducer((_: any, next: string) => {
const digits = next.replace(/\D/g, "");
return moneyFormatter.format(Number(digits) / 100);
}, initialValue);
function handleChange(realChangeFn: Function, formattedValue: string) {
const digits = formattedValue.replace(/\D/g, "");
const realValue = Number(digits) / 100;
realChangeFn(realValue);
}
return (
<FormField
control={props.form.control}
name={props.name}
render={({ field }) => {
field.value = value;
const _change = field.onChange;
return (
<FormItem>
<FormLabel>{props.label}</FormLabel>
<FormControl>
<Input
placeholder={props.placeholder}
type="text"
{...field}
onChange={(ev) => {
setValue(ev.target.value);
handleChange(_change, ev.target.value);
}}
value={value}
/>
</FormControl>
<FormMessage />
</FormItem>
);
}}
/>
);
}
@hspotted
Copy link

Funciona muito bem quando estás a introduzir os dados, mas a apagar ele não funciona

@Sutil
Copy link
Author

Sutil commented Jan 15, 2024

Funciona muito bem quando estás a introduzir os dados, mas a apagar ele não funciona

@hspotted, It worked fine here. Let me know what behavior you expect on this.

Screen.Recording.2024-01-15.at.10.14.44.mov

@DaviJat
Copy link

DaviJat commented Feb 27, 2024

Estou usando o componente e ele funciona perfeitamente, mas quando faço form.reset(data) para recuperar os dados, é o único campo que não atualiza

@Sutil
Copy link
Author

Sutil commented Feb 29, 2024

Estou usando o componente e ele funciona perfeitamente, mas quando faço form.reset(data) para recuperar os dados, é o único campo que não atualiza

Não previ este caso. A linha 51 sempre vai atrapalhar.

Vou ter que mudar a implementação

@DaviJat
Copy link

DaviJat commented Feb 29, 2024

Eu consegui contornar a situação aqui, adicionei um useEffect no componente para atualizar o valor, e estou passando o value pelo formulário como uma props do componente:

useEffect(() => {
    if (props.value) {
      setValue((Number(props.value) * 100).toString());
    }
  }, [props.form, props.value]);
const [balanceValue, setBalanceValue] = useState('');

// setBalanceValue usei onde pego os dados da API

<MoneyInput
            form={form}
            value={balanceValue}
            label="Saldo"
            name="balance"
            placeholder={!isDataLoading ? 'Saldo da carteira...' : 'Carregando...'}
          />

@thailonlucas
Copy link

Eu consegui contornar a situação aqui, adicionei um useEffect no componente para atualizar o valor, e estou passando o value pelo formulário como uma props do componente:

useEffect(() => {
    if (props.value) {
      setValue((Number(props.value) * 100).toString());
    }
  }, [props.form, props.value]);
const [balanceValue, setBalanceValue] = useState('');

// setBalanceValue usei onde pego os dados da API

<MoneyInput
            form={form}
            value={balanceValue}
            label="Saldo"
            name="balance"
            placeholder={!isDataLoading ? 'Saldo da carteira...' : 'Carregando...'}
          />

Eu resolvi dessa forma e deixou o código um pouco mais limpo:

useEffect(() => { setValue(initialValue); }, [initialValue]);

Mas isso porque nao passo o value como prop e continuo utilizando apenas o form como fonte de dado.

@edsonbraz
Copy link

edsonbraz commented May 8, 2024

A little late, but here is how I managed the form.reset() outside the component:

const formData = form.watch(name);

useEffect(() => {
  const formValue = moneyFormatter.format(Number(formData));
  if (formValue !== value) {
    setValue(formValue);
  }
}, [formData, value])

@tiagoluizpoli
Copy link

Funciona muito bem quando estás a introduzir os dados, mas a apagar ele não funciona

Estou tendo esse problema, mas apenas quando pelo celular (chrome mobile).

Pra mim, ao clicar backspack ele apaga, mas move o cursor uma casa a esquerda.

WhatsApp.Video.2024-06-03.at.21.02.51.mp4

No pc está impecável.

@ludioao
Copy link

ludioao commented Jul 11, 2024

@tiagoluizpoli

Tenta colocar o attribute inputmode = decimal

@frontandrews
Copy link

frontandrews commented Oct 10, 2024

Valeu pessoal ajudou a dar um norte, vou deixar meu código aqui caso ajude mais alguém.

use client';
import { useEffect, useState } from 'react';
import { FormControl, FormField, FormItem, FormLabel, FormMessage } from '../ui/form';
import { Input } from '../ui/input';
import { UseFormReturn } from 'react-hook-form';
import { formatCurrency } from '@/lib/utils';

type MoneyInputProps = {
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
  form: UseFormReturn<any>;
  name: string;
  label: string;
  placeholder?: string;
  value?: string | number;
};

export const MoneyInput = (props: MoneyInputProps) => {
  const [inputValue, setInputValue] = useState<string>(props.value ? formatCurrency(Number(props.value)) : '');

  useEffect(() => {
    const formValue = props.form.getValues(props.name);
    if (formValue && formValue !== inputValue) {
      setInputValue(formatCurrency(Number(formValue)));
    }
  }, [props.form, props.name, props.value, inputValue]);

  const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
    const rawValue = e.target.value.replace(/\D/g, '');
    const numericValue = (Number(rawValue) / 100).toFixed(2);
    setInputValue(rawValue);
    props.form.setValue(props.name, numericValue, { shouldValidate: true });
  };

  const handleBlur = () => {
    if (inputValue) {
      const formattedValue = formatCurrency(Number(inputValue) / 100);
      setInputValue(formattedValue);
    }
  };

  const handleFocus = () => {
    const currentValue = props.form.getValues(props.name);
    if (currentValue) {
      setInputValue((Number(currentValue) * 100).toString());
    }
  };

  return (
    <FormField
      control={props.form.control}
      name={props.name}
      render={({ field }) => (
        <FormItem className="flex-1">
          <FormLabel>{props.label}</FormLabel>
          <FormControl>
            <Input
              placeholder={props.placeholder}
              type="text"
              value={inputValue}
              onChange={handleChange}
              onBlur={handleBlur}
              onFocus={handleFocus}
              name={field.name}
            />
          </FormControl>
          <FormMessage />
        </FormItem>
      )}
    />
  );
};
export const formatCurrency = (amount: number, currency: string = 'USD', locale: string = 'en-US'): string => {
  return new Intl.NumberFormat(locale, {
    style: 'currency',
    currency: currency,
  }).format(amount);
};

@renangasperi
Copy link

Based on the examples here, I did it this way using the formContext, with stronger typing and validation to prevent the field from showing R$ 0.00 when empty, displaying the placeholder instead. It works with reset()

import { useEffect, useReducer } from "react";
import { FieldValues, Path, useFormContext } from "react-hook-form";
import {
  FormControl,
  FormField,
  FormItem,
  FormLabel,
  FormMessage,
} from "../ui/form";
import { Input, InputProps } from "../ui/input";

export type InputCurrencyProps<T extends FieldValues> = InputProps & {
  name: Path<T>;
  label?: string;
};

const toCurrency = (value: number) =>
  new Intl.NumberFormat("pt-BR", {
    currency: "BRL",
    style: "currency",
    minimumFractionDigits: 2,
    maximumFractionDigits: 2,
  }).format(value);

function InputCurrency<T extends FieldValues>({
  name,
  label,
  ...props
}: InputCurrencyProps<T>) {
  const { watch, control } = useFormContext();
  const formValue = watch(name);

  const [value, setValue] = useReducer((_: string, next: string) => {
    if (!next) return "";
    const numericValue = Number(next.replace(/\D/g, "")) / 100;
    return numericValue ? toCurrency(numericValue) : "";
  }, "");

  useEffect(() => {
    setValue(formValue ? toCurrency(formValue) : "");
  }, [formValue]);

  return (
    <FormField
      control={control}
      name={name}
      render={({ field }) => (
        <FormItem>
          {label && <FormLabel>{label}</FormLabel>}
          <FormControl>
            <Input
              id={name}
              type="text"
              {...props}
              {...field}
              onChange={(ev) => {
                const inputValue = ev.target.value;
                setValue(inputValue);
                const numericValue =
                  Number(inputValue.replace(/\D/g, "")) / 100;
                field.onChange(numericValue || 0);
              }}
              value={value}
            />
          </FormControl>
          <FormMessage />
        </FormItem>
      )}
    />
  );
}

export default InputCurrency;

@luiznegreiros
Copy link

Adding my two cents: I suggest using the currency type in the input field for better usability:

import { cn } from "@/lib/utils";
import * as React from "react";

type InputType = React.HTMLInputTypeAttribute | "currency";

export interface InputProps extends React.InputHTMLAttributes<HTMLInputElement> {
  currencyFormat?: Intl.NumberFormat;
  type?: InputType;
}

const defaultCurrencyFormat = new Intl.NumberFormat("pt-BR", {
  style: "currency",
  currency: "BRL",
  minimumFractionDigits: 2,
  maximumFractionDigits: 2,
});

const Input = React.forwardRef<HTMLInputElement, InputProps>(
  ({ className, type = "text", currencyFormat, onChange, onFocus, ...props }, ref) => {
    const isCurrency = type === "currency";
    const inputType = isCurrency ? "text" : type;

    const formatCurrency = (value: number) => {
      return (currencyFormat ?? defaultCurrencyFormat).format(value);
    };

    const handleFocus = (e: React.FocusEvent<HTMLInputElement>) => {
      if (isCurrency) {
        const target = e.currentTarget;
        target.setSelectionRange(target.value.length, target.value.length);
      }
      onFocus?.(e);
    };

    const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
      if (isCurrency) {
        const target = e.currentTarget;
        const numericValue = Number(target.value.replace(/\D/g, "")) / 100;
        target.value = formatCurrency(numericValue);
      }
      onChange?.(e);
    };

    return (
      <input
        type={inputType}
        className={cn(
          "flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm",
          "ring-offset-background file:border-0 file:bg-transparent file:text-sm",
          "file:font-medium file:text-foreground placeholder:text-muted-foreground",
          "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
          "focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
          isCurrency && "text-end",
          className,
        )}
        maxLength={isCurrency ? 22 : undefined}
        onFocus={handleFocus}
        onChange={handleChange}
        ref={ref}
        {...props}
      />
    );
  },
);
Input.displayName = "Input";

export { Input };

usage:

              <FormField
                control={form.control}
                name="balance"
                render={({ field }) => (
                  <FormItem className="grid grid-cols-4 items-center gap-4 space-y-0">
                    <FormLabel className="text-right">Balance</FormLabel>
                    <FormControl>
                      <Input type="currency" {...field} className="col-span-3" />
                    </FormControl>
                    <FormMessage />
                  </FormItem>
                )}
              />

@Matheus8174
Copy link

Who is using react-native can do

<Controller
  name="price"
  control={control}
  render={({ field }) => (
    <Input
      ref={field.ref}
      onChangeText={(text) => {
        const digits = text.replace(/\D/g, '');
        const realValue = Number(digits) / 100;

        field.onChange(realValue);
      }}
      value={moneyFormatter.format(field.value)}
      keyboardType="numeric"
      placeholder="R$ 50"
      className="border-gray-200/20 bg-white-100 p-5 max-w-40"
    />
  )}
/>

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment