Campo de Moeda
Campo de dinheiro que formata enquanto se digita (Intl, qualquer moeda e localidade) e guarda o número. Com um valor calculado de referência, oferece restaurá-lo quando a pessoa o substitui.
Playground
Props
Geradas dos tipos TypeScript do componente; a descrição vem dos comentários /** */.
<CampoDeMoeda>
Campo de valor em dinheiro que formata enquanto se digita: os dígitos entram pela direita, como numa maquininha ("1" → 0,01; "123" → 1,23). Guarda o número, não o texto. Com `valorCalculado`, mostra um botão para restaurar o valor calculado quando a pessoa o substitui.
| Prop | Tipo | Padrão | Descrição |
|---|---|---|---|
| rotuloobrigatória | string | — | |
| valorobrigatória | number | null | — | null = campo vazio. |
| aoMudarobrigatória | (valor: number | null) => void | — | |
| moeda | string | "BRL" | Código ISO 4217 ("BRL", "USD", "EUR"); define o símbolo e as casas decimais. |
| localidade | string | "pt-BR" | |
| valorCalculado | number | — | Valor de referência (uma fórmula, por exemplo). Se o campo for diferente dele, aparece o botão de restaurar. |
| rotuloRestaurar | (valorFormatado: string) => string | (valorFormatado) => `Restaurar ${valorFormatado}` | Texto do botão de restaurar, a partir do valor calculado já formatado. |
| ajuda | string | — | Texto de apoio abaixo do campo. |
| placeholder | string | — | |
| desativado | boolean | false | |
| nome | string | — | Atributo name do input, para formulários. |
Código
stories.tsx tem os exemplos usados no preview e no playground.
"use client";
import { useId, useMemo, useRef, type ChangeEvent } from "react";
/** Até 15 dígitos (centavos incluídos): fica dentro da precisão exata de um number. */
const MAXIMO_DE_DIGITOS = 15;
/**
* Campo de valor em dinheiro que formata enquanto se digita: os dígitos entram
* pela direita, como numa maquininha ("1" → 0,01; "123" → 1,23). Guarda o número,
* não o texto. Com `valorCalculado`, mostra um botão para restaurar o valor
* calculado quando a pessoa o substitui.
*/
export function CampoDeMoeda({
rotulo,
valor,
aoMudar,
moeda = "BRL",
localidade = "pt-BR",
valorCalculado,
rotuloRestaurar = (valorFormatado) => `Restaurar ${valorFormatado}`,
ajuda,
placeholder,
desativado = false,
nome,
}: {
rotulo: string;
/** null = campo vazio. */
valor: number | null;
aoMudar: (valor: number | null) => void;
/** Código ISO 4217 ("BRL", "USD", "EUR"); define o símbolo e as casas decimais. */
moeda?: string;
localidade?: string;
/** Valor de referência (uma fórmula, por exemplo). Se o campo for diferente dele, aparece o botão de restaurar. */
valorCalculado?: number;
/** Texto do botão de restaurar, a partir do valor calculado já formatado. */
rotuloRestaurar?: (valorFormatado: string) => string;
/** Texto de apoio abaixo do campo. */
ajuda?: string;
placeholder?: string;
desativado?: boolean;
/** Atributo name do input, para formulários. */
nome?: string;
}) {
const id = useId();
const campo = useRef<HTMLInputElement>(null);
const formato = useMemo(() => {
const comMoeda = new Intl.NumberFormat(localidade, { style: "currency", currency: moeda });
const casas = comMoeda.resolvedOptions().maximumFractionDigits ?? 2;
const partes = comMoeda.formatToParts(1);
const posicaoDoSimbolo = partes.findIndex((p) => p.type === "currency");
return {
comMoeda,
casas,
numero: new Intl.NumberFormat(localidade, { minimumFractionDigits: casas, maximumFractionDigits: casas }),
simbolo: partes[posicaoDoSimbolo]?.value ?? moeda,
simboloAntes: posicaoDoSimbolo < partes.findIndex((p) => p.type === "integer"),
};
}, [moeda, localidade]);
const emUnidades = (n: number) => Math.round(n * 10 ** formato.casas);
const editado = valorCalculado !== undefined && (valor === null || emUnidades(valor) !== emUnidades(valorCalculado));
function aoDigitar(e: ChangeEvent<HTMLInputElement>) {
const digitos = e.target.value.replace(/\D/g, "").replace(/^0+/, "").slice(0, MAXIMO_DE_DIGITOS);
if (!digitos) {
// Apagar até o zero esvazia o campo; digitar "0" num campo vazio dá zero
const apagando = (e.nativeEvent as InputEvent).inputType?.startsWith("delete");
aoMudar(apagando || e.target.value === "" ? null : 0);
return;
}
aoMudar(Number(digitos) / 10 ** formato.casas);
}
const idAjuda = `${id}-ajuda`;
const simbolo = (
<span aria-hidden="true" className="shrink-0 text-sm text-muted">
{formato.simbolo}
</span>
);
return (
<div className="flex w-full flex-col gap-2">
<div className="flex min-h-6 flex-wrap items-center justify-between gap-x-3 gap-y-1">
<label htmlFor={id} className="text-sm font-semibold text-ink">
{rotulo}
{/* O símbolo ao lado do número é decorativo; a moeda entra no nome do campo */}
<span className="sr-only"> ({formato.simbolo})</span>
</label>
{valorCalculado !== undefined && editado && !desativado && (
<button
type="button"
onClick={() => {
aoMudar(valorCalculado ?? null);
// O botão some depois de restaurar: o foco vai para o campo em vez de se perder
campo.current?.focus();
}}
className="inline-flex cursor-pointer items-center gap-1 rounded-sm text-xs font-medium text-ink-2 outline-accent hover:text-accent focus-visible:outline-2 focus-visible:outline-offset-2"
>
<svg aria-hidden="true" viewBox="0 0 24 24" className="size-3.5" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round">
<path d="M3 12a9 9 0 1 0 3-6.7L3 8" />
<path d="M3 3v5h5" />
</svg>
{rotuloRestaurar(formato.comMoeda.format(valorCalculado))}
</button>
)}
</div>
<div
className={`flex min-h-11 items-center gap-2 rounded-lg border bg-page px-3 outline-accent focus-within:border-accent focus-within:outline-2 focus-within:outline-offset-2 ${
desativado ? "border-rule opacity-60" : "border-rule-strong"
}`}
>
{formato.simboloAntes && simbolo}
<input
ref={campo}
id={id}
name={nome}
inputMode="numeric"
autoComplete="off"
value={valor === null ? "" : formato.numero.format(valor)}
onChange={aoDigitar}
placeholder={placeholder ?? formato.numero.format(0)}
disabled={desativado}
aria-describedby={ajuda ? idAjuda : undefined}
className="min-w-0 flex-1 bg-transparent py-2 text-right text-base text-ink tabular-nums outline-none placeholder:text-muted disabled:cursor-not-allowed"
/>
{!formato.simboloAntes && simbolo}
</div>
{ajuda && (
<p id={idAjuda} className="text-xs text-muted">
{ajuda}
</p>
)}
</div>
);
}
import { useState } from "react";
import { defineStories } from "@/registry/stories";
import { CampoDeMoeda } from "./CampoDeMoeda";
function Exemplo({
rotulo,
moeda,
localidade,
inicial,
comCalculado,
ajuda,
}: {
rotulo: string;
moeda: string;
localidade: string;
inicial: number;
comCalculado: boolean;
ajuda: string;
}) {
const [valor, setValor] = useState<number | null>(inicial > 0 ? inicial : null);
return (
<div className="flex w-full max-w-sm flex-col gap-3">
<CampoDeMoeda
rotulo={rotulo}
valor={valor}
aoMudar={setValor}
moeda={moeda}
localidade={localidade}
valorCalculado={comCalculado ? 39072 : undefined}
ajuda={ajuda || undefined}
/>
<p className="text-xs text-muted">
Valor guardado: <code>{valor === null ? "null" : valor}</code>
</p>
</div>
);
}
/** Dois campos lado a lado, um deles já alterado em relação ao calculado. */
function ExemploDeRevisao() {
const [frete, setFrete] = useState<number | null>(12500);
const [instalacao, setInstalacao] = useState<number | null>(26572.4);
return (
<div className="grid w-full max-w-2xl gap-4 sm:grid-cols-2">
<div className="rounded-xl border border-rule bg-surface p-4">
<CampoDeMoeda rotulo="Frete" valor={frete} aoMudar={setFrete} valorCalculado={12500} />
</div>
<div className="rounded-xl border border-rule bg-surface p-4">
<CampoDeMoeda rotulo="Instalação" valor={instalacao} aoMudar={setInstalacao} valorCalculado={24980} />
</div>
</div>
);
}
export default defineStories({
args: {
rotulo: "Valor da proposta",
moeda: "BRL",
localidade: "pt-BR",
inicial: 39072,
comCalculado: true,
ajuda: "Os dígitos entram pela direita: os dois últimos são os centavos.",
},
controls: {
rotulo: { type: "text" },
moeda: { type: "select", options: ["BRL", "USD", "EUR", "JPY"] },
localidade: { type: "select", options: ["pt-BR", "en-US", "de-DE", "ja-JP"] },
inicial: { type: "number", label: "valor inicial (0 = vazio)", min: 0, step: 100 },
comCalculado: { type: "boolean", label: "valorCalculado (39.072)" },
ajuda: { type: "text" },
},
render: (a) => <Exemplo key={`${a.inicial}`} {...a} />,
stories: [
{ name: "Reais" },
{ name: "Alterado: restaurar", args: { inicial: 41500 } },
{ name: "Vazio", args: { inicial: 0, comCalculado: false, rotulo: "Desconto" } },
{ name: "Euro (de-DE)", args: { moeda: "EUR", localidade: "de-DE", comCalculado: false, ajuda: "" } },
{ name: "Revisão de valores", render: () => <ExemploDeRevisao /> },
],
});
Para usar em outro projeto, copie também
Os componentes de que este depende e o tema. O CSS do tema é um arquivo só: importe depois do Tailwind.
Tema escolhido no cabeçalho (com a ponte para o Tailwind)
Ver o CSS do tema
/* Tema "Editorial", gerado no Component Vault.
Importe depois de @import "tailwindcss". Carregue as fontes "Archivo" e "Inter" no projeto. */
@theme inline {
--color-page: var(--page);
--color-surface: var(--surface);
--color-track: var(--track);
--color-grid: var(--grid);
--color-ink: var(--ink);
--color-ink-2: var(--ink-2);
--color-muted: var(--muted);
--color-rule: var(--rule);
--color-rule-strong: var(--rule-strong);
--color-axis: var(--axis);
--color-accent: var(--accent);
--color-success: var(--success);
--color-warning: var(--warning);
--color-critical: var(--critical);
--font-display: var(--fonte-titulo);
--shadow-flutuante: var(--sombra-flutuante);
}
:root {
color-scheme: light dark;
--page: light-dark(#ffffff, #0f0f0e);
--surface: light-dark(#f5f5f5, #1a1a19);
--track: light-dark(#f0f0f0, #262624);
--grid: light-dark(#ededed, #232321);
--ink: light-dark(#111111, #f0efec);
--ink-2: light-dark(#4a4a4a, #c3c2b7);
--muted: light-dark(#666666, #a3a29a);
--rule: light-dark(#e3e3e3, #2c2c2a);
--rule-strong: light-dark(#111111, #f0efec);
--axis: light-dark(#9a9a9a, #6b6a64);
--accent: light-dark(#1a56db, #7aa2f7);
--success: light-dark(#13865c, #3fc48f);
--warning: #fab219;
--critical: #d03b3b;
--series-1: light-dark(#2a78d6, #3987e5);
--series-2: light-dark(#eb6834, #d95926);
--series-3: light-dark(#1baf7a, #199e70);
--series-4: light-dark(#eda100, #c98500);
--series-5: light-dark(#e87ba4, #d55181);
--series-6: #008300;
--series-7: light-dark(#6250d6, #9085e9);
--series-8: light-dark(#e34948, #e66767);
--fonte-titulo: "Archivo", system-ui, -apple-system, "Segoe UI", sans-serif;
--fonte-texto: "Inter", system-ui, -apple-system, "Segoe UI", sans-serif;
--spacing: 0.25rem;
--text-xs: 0.8rem;
--text-sm: 0.8944rem;
--text-base: 1rem;
--text-lg: 1.118rem;
--text-xl: 1.25rem;
--text-2xl: 1.5625rem;
--text-3xl: 1.9531rem;
--text-4xl: 2.4414rem;
--text-5xl: 3.0518rem;
--text-6xl: 3.8147rem;
--text-7xl: 4.7684rem;
--radius-xs: 2px;
--radius-sm: 4px;
--radius-md: 6px;
--radius-lg: 8px;
--radius-xl: 12px;
--radius-2xl: 16px;
--radius-3xl: 24px;
--radius-4xl: 32px;
--sombra-flutuante: 0 8px 24px -6px rgb(0 0 0 / 0.14), 0 2px 6px -2px rgb(0 0 0 / 0.08);
color: var(--ink);
font-family: var(--fonte-texto);
}
Na vitrine, a ponte com o Tailwind fica em src/themes/tokens.css:
/* Ponte entre os tokens do tema e o Tailwind.
Os componentes da biblioteca usam classes comuns do Tailwind (p-5, text-sm,
rounded-xl...) e as cores semânticas abaixo (bg-page, text-ink...). Dentro de
[data-theme="vault"], o tema redefine as variáveis que essas classes leem:
--spacing, --text-*, --radius-* e as cores. Os valores vêm do tema escolhido
na vitrine (src/themes/presets.ts) e são gerados por themeCss(). */
@theme inline {
--color-page: var(--page);
--color-surface: var(--surface);
--color-ink: var(--ink);
--color-ink-2: var(--ink-2);
--color-muted: var(--muted);
--color-rule: var(--rule);
--color-rule-strong: var(--rule-strong);
--color-track: var(--track);
--color-grid: var(--grid);
--color-axis: var(--axis);
--color-accent: var(--accent);
--color-success: var(--success);
--color-warning: var(--warning);
--color-critical: var(--critical);
--font-display: var(--fonte-titulo);
--shadow-flutuante: var(--sombra-flutuante);
}