Campo de Formulário
Campo com rótulo, ícone, ajuda e erro já ligados ao controle por aria, e os controles no estilo do tema: entrada com máscara (telefone, CNPJ, CEP), seleção, área de texto, e senha com botão de mostrar, em três tamanhos. Junta o formulário da Nexco com os campos da Acktalabs.
Playground
Props
Geradas dos tipos TypeScript do componente; a descrição vem dos comentários /** */.
<Campo>
Rótulo, ícone opcional à esquerda, controle e mensagem de erro ou de ajuda, já ligados por id (`aria-invalid` e `aria-describedby` vão para o controle). O filho é o controle: Entrada, Selecao, AreaDeTexto, EntradaDeSenha, BarraDeBusca ou qualquer input/select/textarea.
| Prop | Tipo | Padrão | Descrição |
|---|---|---|---|
| rotuloobrigatória | ReactNode | — | |
| icone | string | number | bigint | boolean | ReactElement<unknown, string | JSXElementConstructor<any>> | Iterable<ReactNode> | ReactPortal | Promise<AwaitedReactNode> | — | Ícone (SVG) dentro do controle, à esquerda. Na BarraDeBusca a lupa já vem com ela; num <input> comum, abra o espaço com `classesDoControle({ comIcone: true })`. |
| erro | string | — | Mensagem de erro; deixa a borda vermelha e é lida junto com o campo. |
| ajuda | string | number | bigint | boolean | ReactElement<unknown, string | JSXElementConstructor<any>> | Iterable<ReactNode> | ReactPortal | Promise<AwaitedReactNode> | — | Texto de apoio abaixo do campo, quando não há erro. |
| obrigatorio | boolean | false | Marca o rótulo com um asterisco e avisa leitores de tela (`aria-required`). |
| tamanho | "sm" | "md" | "lg" | — | Tamanho do controle e do rótulo. Padrão: o do controle filho, ou "md". |
| childrenobrigatória | ReactElement<PreenchidoPeloCampo, string | JSXElementConstructor<any>> | — |
<Entrada>
Campo de texto no estilo do tema, com máscara opcional. `onChange` recebe o valor já formatado.
Também aceita os 306 atributos nativos do elemento HTML (onClick, aria-*, disabled...), repassados ao elemento.
| Prop | Tipo | Padrão | Descrição |
|---|---|---|---|
| comIcone | boolean | — | Preenchido pelo Campo quando ele tem ícone. |
| erro | boolean | — | Borda de erro. Dentro de um Campo, vem da prop `erro` dele. |
| tamanho | "sm" | "md" | "lg" | "md" | |
| mascara | string | — | Máscara aplicada ao digitar: "(00) 00000-0000", "00.000.000/0000-00", "00000-000"... |
| onChange | (valor: string) => void | — | Recebe o valor já formatado pela máscara. |
<AreaDeTexto>
Texto de várias linhas no mesmo estilo; a altura vem de `rows` e a pessoa pode esticar na vertical.
Também aceita os 291 atributos nativos do elemento HTML (onClick, aria-*, disabled...), repassados ao elemento.
| Prop | Tipo | Padrão | Descrição |
|---|---|---|---|
| comIcone | boolean | — | Preenchido pelo Campo quando ele tem ícone. |
| erro | boolean | — | Borda de erro. Dentro de um Campo, vem da prop `erro` dele. |
| tamanho | "sm" | "md" | "lg" | "md" | |
| onChange | (valor: string) => void | — |
<Selecao>
Seleção nativa no estilo do tema, com a seta desenhada.
Também aceita os 284 atributos nativos do elemento HTML (onClick, aria-*, disabled...), repassados ao elemento.
| Prop | Tipo | Padrão | Descrição |
|---|---|---|---|
| comIcone | boolean | — | Preenchido pelo Campo quando ele tem ícone. |
| erro | boolean | — | Borda de erro. Dentro de um Campo, vem da prop `erro` dele. |
| tamanho | "sm" | "md" | "lg" | "md" | |
| opcoesobrigatória | { valor: string; rotulo: string; }[] | — | |
| vazio | string | — | Primeira opção vazia ("Selecione..."). |
| onChange | (valor: string) => void | — | Recebe o valor da opção escolhida. |
<EntradaDeSenha>
Senha com o botão de mostrar e ocultar. O botão é um alternador: o nome não muda e `aria-pressed` diz se a senha está à mostra; o ícone troca o olho.
Também aceita os 305 atributos nativos do elemento HTML (onClick, aria-*, disabled...), repassados ao elemento.
| Prop | Tipo | Padrão | Descrição |
|---|---|---|---|
| comIcone | boolean | — | Preenchido pelo Campo quando ele tem ícone. |
| erro | boolean | — | Borda de erro. Dentro de um Campo, vem da prop `erro` dele. |
| tamanho | "sm" | "md" | "lg" | "md" | |
| onChange | (valor: string) => void | — | |
| rotuloMostrar | string | "Mostrar senha" | Nome do botão do olho. O estado (mostrando ou não) vai em `aria-pressed`. |
Código
stories.tsx tem os exemplos usados no preview e no playground.
"use client";
import {
cloneElement,
isValidElement,
useId,
useState,
type InputHTMLAttributes,
type ReactElement,
type ReactNode,
type SelectHTMLAttributes,
type TextareaHTMLAttributes,
} from "react";
/**
* Aplica uma máscara simples ao que foi digitado: `0` é dígito, `A` é letra e
* `*` é qualquer caractere; o resto da máscara é fixo. "(00) 00000-0000"
* transforma "11987654321" em "(11) 98765-4321".
*/
export function aplicarMascara(valor: string, mascara: string) {
const aceita = { "0": /\d/, A: /[a-z]/i, "*": /./ } as Record<string, RegExp>;
const brutos = [...valor].filter((c) => /[a-z\d]/i.test(c));
let resultado = "";
let i = 0;
for (const simbolo of mascara) {
if (i >= brutos.length) break;
const regra = aceita[simbolo];
if (!regra) {
resultado += simbolo;
continue;
}
// Descarta o que não serve para esta posição (uma letra onde vai dígito)
while (i < brutos.length && !regra.test(brutos[i])) i++;
if (i < brutos.length) resultado += brutos[i++];
}
return resultado;
}
/**
* Medidas de cada tamanho. Classes inteiras de propósito: o Tailwind só gera as
* classes que aparecem escritas no código. `folga` abre espaço à direita para a
* seta da seleção ou para os botões de mostrar senha e limpar busca.
*/
export const TAMANHOS = {
sm: {
altura: "h-9",
raio: "rounded-lg",
texto: "text-sm",
vertical: "py-2",
esquerda: "pl-3",
comIcone: "pl-9",
direita: "pr-3",
folga: "pr-9",
icone: "left-3 [&_svg]:size-4",
iconeNoTopo: "top-2.5",
acao: "right-1 size-7 [&_svg]:size-4",
rotulo: "text-xs",
espaco: "mt-1.5",
},
md: {
altura: "h-12",
raio: "rounded-xl",
texto: "text-sm",
vertical: "py-3",
esquerda: "pl-4",
comIcone: "pl-11",
direita: "pr-4",
folga: "pr-11",
icone: "left-4 [&_svg]:size-[18px]",
iconeNoTopo: "top-3.5",
acao: "right-1.5 size-9 [&_svg]:size-[18px]",
rotulo: "text-sm",
espaco: "mt-2",
},
lg: {
altura: "h-14",
raio: "rounded-xl",
texto: "text-base",
vertical: "py-3.5",
esquerda: "pl-5",
comIcone: "pl-12",
direita: "pr-5",
folga: "pr-14",
icone: "left-4 [&_svg]:size-5",
iconeNoTopo: "top-4",
acao: "right-2 size-10 [&_svg]:size-5",
rotulo: "text-sm",
espaco: "mt-2",
},
} as const;
export type TamanhoDoCampo = keyof typeof TAMANHOS;
/** Classes do controle: tamanho, espaço para ícone e ações, e a borda de erro quando `erro` é verdadeiro. */
export function classesDoControle({
erro = false,
comIcone = false,
tamanho = "md",
folgaDireita = false,
multilinha = false,
}: {
erro?: boolean;
/** Abre espaço à esquerda para o ícone. */
comIcone?: boolean;
tamanho?: TamanhoDoCampo;
/** Abre espaço à direita para uma seta ou um botão. */
folgaDireita?: boolean;
/** Área de texto: altura pelo conteúdo, e não fixa. */
multilinha?: boolean;
} = {}) {
const t = TAMANHOS[tamanho];
return [
"block w-full border bg-surface text-ink outline-none transition-[color,background-color,border-color,box-shadow] placeholder:text-muted focus:bg-page focus:ring-3 disabled:cursor-not-allowed disabled:opacity-60",
t.raio,
t.texto,
multilinha ? `${t.vertical} min-h-24 resize-y leading-relaxed` : t.altura,
comIcone ? t.comIcone : t.esquerda,
folgaDireita ? t.folga : t.direita,
erro
? "border-critical focus:border-critical focus:ring-critical/15"
: "border-rule hover:border-axis focus:border-accent focus:ring-accent/15",
].join(" ");
}
/** O que o Campo preenche no controle filho. */
type PreenchidoPeloCampo = {
id?: string;
"aria-invalid"?: boolean;
"aria-describedby"?: string;
"aria-required"?: boolean;
comIcone?: boolean;
erro?: boolean;
tamanho?: TamanhoDoCampo;
};
/**
* Rótulo, ícone opcional à esquerda, controle e mensagem de erro ou de ajuda,
* já ligados por id (`aria-invalid` e `aria-describedby` vão para o controle).
* O filho é o controle: Entrada, Selecao, AreaDeTexto, EntradaDeSenha,
* BarraDeBusca ou qualquer input/select/textarea.
*/
export function Campo({
rotulo,
icone,
erro,
ajuda,
obrigatorio = false,
tamanho,
children,
}: {
rotulo: ReactNode;
/**
* Ícone (SVG) dentro do controle, à esquerda. Na BarraDeBusca a lupa já vem
* com ela; num <input> comum, abra o espaço com `classesDoControle({ comIcone: true })`.
*/
icone?: ReactNode;
/** Mensagem de erro; deixa a borda vermelha e é lida junto com o campo. */
erro?: string;
/** Texto de apoio abaixo do campo, quando não há erro. */
ajuda?: ReactNode;
/** Marca o rótulo com um asterisco e avisa leitores de tela (`aria-required`). */
obrigatorio?: boolean;
/** Tamanho do controle e do rótulo. Padrão: o do controle filho, ou "md". */
tamanho?: TamanhoDoCampo;
children: ReactElement<PreenchidoPeloCampo>;
}) {
const idGerado = useId();
const valido = isValidElement(children);
const propsDoFilho: PreenchidoPeloCampo = valido ? children.props : {};
const id = propsDoFilho.id || idGerado;
const idMensagem = `${id}-mensagem`;
const mensagem = erro ?? ajuda;
const medida = tamanho ?? propsDoFilho.tamanho ?? "md";
const t = TAMANHOS[medida];
const tipo = valido ? children.type : null;
// A BarraDeBusca (de outra pasta) se marca com `temLupa`, sem import circular
const comLupa = Boolean(tipo && (tipo as { temLupa?: boolean }).temLupa);
// Os controles da biblioteca recebem também o estado visual; um <input> comum, só o que é HTML
const nosso =
tipo === Entrada || tipo === Selecao || tipo === AreaDeTexto || tipo === EntradaDeSenha || comLupa;
const comIcone = Boolean(icone) && !comLupa;
const descritoPor = [propsDoFilho["aria-describedby"], mensagem ? idMensagem : undefined].filter(Boolean).join(" ");
const controle = valido
? cloneElement(children, {
id,
"aria-invalid": erro ? true : propsDoFilho["aria-invalid"],
"aria-describedby": descritoPor || undefined,
"aria-required": obrigatorio || propsDoFilho["aria-required"] || undefined,
...(nosso ? { erro: Boolean(erro), tamanho: medida } : {}),
// A busca não recebe comIcone: a lupa dela já ocupa a esquerda
...(nosso && !comLupa ? { comIcone } : {}),
})
: children;
return (
<div>
<label htmlFor={id} className={`block font-medium text-ink ${t.rotulo}`}>
{rotulo}
{obrigatorio && (
<span aria-hidden className="ml-1 text-critical">
*
</span>
)}
</label>
<div className={`relative ${t.espaco}`}>
{controle}
{/* Depois do controle no DOM, para ficar por cima do fundo dele */}
{comIcone && (
<span
aria-hidden
className={`pointer-events-none absolute text-muted ${t.icone} ${
tipo === AreaDeTexto ? t.iconeNoTopo : "top-1/2 -translate-y-1/2"
}`}
>
{icone}
</span>
)}
</div>
{mensagem && (
<p id={idMensagem} className={`mt-1.5 text-xs ${erro ? "text-critical" : "text-muted"}`}>
{mensagem}
</p>
)}
</div>
);
}
export type Visual = {
/** Preenchido pelo Campo quando ele tem ícone. */
comIcone?: boolean;
/** Borda de erro. Dentro de um Campo, vem da prop `erro` dele. */
erro?: boolean;
tamanho?: TamanhoDoCampo;
};
type PropsDaEntrada = Omit<InputHTMLAttributes<HTMLInputElement>, "onChange" | "size"> &
Visual & {
/** Máscara aplicada ao digitar: "(00) 00000-0000", "00.000.000/0000-00", "00000-000"... */
mascara?: string;
/** Recebe o valor já formatado pela máscara. */
onChange?: (valor: string) => void;
};
/** Campo de texto no estilo do tema, com máscara opcional. `onChange` recebe o valor já formatado. */
export function Entrada({ mascara, onChange, comIcone, erro, tamanho = "md", className = "", ...props }: PropsDaEntrada) {
return (
<input
{...props}
onChange={(e) => onChange?.(mascara ? aplicarMascara(e.target.value, mascara) : e.target.value)}
className={`${classesDoControle({ erro, comIcone, tamanho })} ${className}`}
/>
);
}
type PropsDaArea = Omit<TextareaHTMLAttributes<HTMLTextAreaElement>, "onChange"> &
Visual & {
onChange?: (valor: string) => void;
};
/** Texto de várias linhas no mesmo estilo; a altura vem de `rows` e a pessoa pode esticar na vertical. */
export function AreaDeTexto({ onChange, comIcone, erro, tamanho = "md", rows = 4, className = "", ...props }: PropsDaArea) {
return (
<textarea
{...props}
rows={rows}
onChange={(e) => onChange?.(e.target.value)}
className={`${classesDoControle({ erro, comIcone, tamanho, multilinha: true })} ${className}`}
/>
);
}
type PropsDaSelecao = Omit<SelectHTMLAttributes<HTMLSelectElement>, "onChange" | "size"> &
Visual & {
opcoes: Array<{ valor: string; rotulo: string }>;
/** Primeira opção vazia ("Selecione..."). */
vazio?: string;
/** Recebe o valor da opção escolhida. */
onChange?: (valor: string) => void;
};
/** Seleção nativa no estilo do tema, com a seta desenhada. */
export function Selecao({ opcoes, vazio, onChange, comIcone, erro, tamanho = "md", className = "", ...props }: PropsDaSelecao) {
const t = TAMANHOS[tamanho];
return (
<div className="relative">
<select
{...props}
onChange={(e) => onChange?.(e.target.value)}
className={`${classesDoControle({ erro, comIcone, tamanho, folgaDireita: true })} cursor-pointer appearance-none ${className}`}
>
{vazio !== undefined && <option value="">{vazio}</option>}
{opcoes.map((o) => (
<option key={o.valor} value={o.valor}>
{o.rotulo}
</option>
))}
</select>
<span aria-hidden className={`pointer-events-none absolute top-1/2 grid -translate-y-1/2 place-items-center text-muted ${t.acao}`}>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={1.6} strokeLinecap="round" strokeLinejoin="round">
<path d="m6 9 6 6 6-6" />
</svg>
</span>
</div>
);
}
export const BOTAO_INTERNO =
"absolute top-1/2 grid -translate-y-1/2 cursor-pointer place-items-center rounded-lg text-muted outline-accent transition-colors hover:bg-track hover:text-ink focus-visible:outline-2 disabled:cursor-not-allowed disabled:opacity-50";
type PropsDaSenha = Omit<InputHTMLAttributes<HTMLInputElement>, "onChange" | "type" | "size"> &
Visual & {
onChange?: (valor: string) => void;
/** Nome do botão do olho. O estado (mostrando ou não) vai em `aria-pressed`. */
rotuloMostrar?: string;
};
/**
* Senha com o botão de mostrar e ocultar. O botão é um alternador: o nome não
* muda e `aria-pressed` diz se a senha está à mostra; o ícone troca o olho.
*/
export function EntradaDeSenha({
onChange,
comIcone,
erro,
tamanho = "md",
rotuloMostrar = "Mostrar senha",
className = "",
...props
}: PropsDaSenha) {
const [visivel, setVisivel] = useState(false);
return (
<div className="relative">
<input
{...props}
type={visivel ? "text" : "password"}
onChange={(e) => onChange?.(e.target.value)}
className={`${classesDoControle({ erro, comIcone, tamanho, folgaDireita: true })} ${className}`}
/>
<button
type="button"
onClick={() => setVisivel((v) => !v)}
disabled={props.disabled}
aria-pressed={visivel}
aria-label={rotuloMostrar}
aria-controls={props.id}
title={rotuloMostrar}
className={`${BOTAO_INTERNO} ${TAMANHOS[tamanho].acao}`}
>
<svg aria-hidden viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={1.6} strokeLinecap="round" strokeLinejoin="round">
{visivel ? (
<>
<path d="M10.6 5.1A10 10 0 0 1 12 5c6.4 0 10 7 10 7a17 17 0 0 1-2.6 3.5M6.6 6.6A17 17 0 0 0 2 12s3.6 7 10 7a9.7 9.7 0 0 0 5.4-1.6" />
<path d="M9.9 9.9a3 3 0 0 0 4.2 4.2" />
<path d="m3 3 18 18" />
</>
) : (
<>
<path d="M2 12s3.6-7 10-7 10 7 10 7-3.6 7-10 7S2 12 2 12Z" />
<circle cx="12" cy="12" r="3" />
</>
)}
</svg>
</button>
</div>
);
}
import { useState, type FormEvent, type ReactNode } from "react";
import { Aviso } from "@/library/aviso/Aviso";
import { defineStories } from "@/registry/stories";
import { AreaDeTexto, Campo, Entrada, EntradaDeSenha, Selecao, type TamanhoDoCampo } from "./Campo";
function Traco({ children }: { children: ReactNode }) {
return (
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={1.6}
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden
>
{children}
</svg>
);
}
const ICONES = {
pessoa: (
<Traco>
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2" />
<circle cx="12" cy="7" r="4" />
</Traco>
),
email: (
<Traco>
<rect x="2" y="4" width="20" height="16" rx="2" />
<path d="m2 7 10 6 10-6" />
</Traco>
),
telefone: (
<Traco>
<path d="M22 16.9v3a2 2 0 0 1-2.2 2 19.8 19.8 0 0 1-8.6-3.1 19.5 19.5 0 0 1-6-6A19.8 19.8 0 0 1 2.1 4.2 2 2 0 0 1 4.1 2h3a2 2 0 0 1 2 1.7c.1 1 .4 1.9.7 2.8a2 2 0 0 1-.5 2.1L8.1 9.9a16 16 0 0 0 6 6l1.3-1.3a2 2 0 0 1 2.1-.4c.9.3 1.8.5 2.8.6a2 2 0 0 1 1.7 2.1Z" />
</Traco>
),
empresa: (
<Traco>
<path d="M4 21V6a2 2 0 0 1 2-2h7a2 2 0 0 1 2 2v15" />
<path d="M15 10h3a2 2 0 0 1 2 2v9" />
<path d="M2 21h20M8 8h3M8 12h3M8 16h3" />
</Traco>
),
local: (
<Traco>
<path d="M20 10c0 6-8 12-8 12s-8-6-8-12a8 8 0 0 1 16 0Z" />
<circle cx="12" cy="10" r="3" />
</Traco>
),
cadeado: (
<Traco>
<rect x="4" y="11" width="16" height="10" rx="2" />
<path d="M8 11V7a4 4 0 0 1 8 0v4" />
</Traco>
),
balao: (
<Traco>
<path d="M21 12a8 8 0 0 1-11.6 7.1L4 20l1-4.6A8 8 0 1 1 21 12Z" />
</Traco>
),
};
const BOTAO =
"inline-flex h-12 cursor-pointer items-center justify-center rounded-full bg-accent px-7 text-sm font-medium text-page outline-accent transition-colors hover:bg-accent/90 focus-visible:outline-2 focus-visible:outline-offset-2";
const ASSUNTOS = [
{ valor: "orcamento", rotulo: "Pedir orçamento" },
{ valor: "suporte", rotulo: "Suporte técnico" },
{ valor: "parceria", rotulo: "Parcerias" },
{ valor: "outro", rotulo: "Outro assunto" },
];
type Contato = { nome: string; email: string; telefone: string; cnpj: string; assunto: string; mensagem: string };
const VAZIO: Contato = { nome: "", email: "", telefone: "", cnpj: "", assunto: "", mensagem: "" };
function validar(c: Contato): Partial<Record<keyof Contato, string>> {
return {
nome: c.nome.trim().length < 3 ? "Informe o nome completo." : undefined,
email: /^\S+@\S+\.\S+$/.test(c.email) ? undefined : "Informe um e-mail válido, como voce@empresa.com.",
telefone: c.telefone.length < 14 ? "Informe o telefone com DDD." : undefined,
cnpj: c.cnpj && c.cnpj.length < 18 ? "O CNPJ tem 14 dígitos." : undefined,
assunto: c.assunto ? undefined : "Escolha um assunto.",
mensagem: c.mensagem.trim().length < 10 ? "Escreva ao menos 10 caracteres." : undefined,
};
}
function FormularioDeContato() {
const [dados, setDados] = useState<Contato>(VAZIO);
const [tentou, setTentou] = useState(false);
const [enviado, setEnviado] = useState(false);
// Os erros aparecem depois da primeira tentativa de envio e somem à medida que a pessoa corrige
const erros = tentou ? validar(dados) : {};
const mudar = (campo: keyof Contato) => (valor: string) => setDados((d) => ({ ...d, [campo]: valor }));
function enviar(e: FormEvent<HTMLFormElement>) {
e.preventDefault();
setTentou(true);
const form = e.currentTarget;
if (Object.values(validar(dados)).some(Boolean)) {
setEnviado(false);
// Leva o foco ao primeiro campo com erro, depois que as mensagens aparecem
requestAnimationFrame(() => form.querySelector<HTMLElement>('[aria-invalid="true"]')?.focus());
return;
}
setEnviado(true);
setDados(VAZIO);
setTentou(false);
}
return (
<form onSubmit={enviar} noValidate className="grid w-full max-w-2xl gap-5 sm:grid-cols-2">
<Campo rotulo="Nome" icone={ICONES.pessoa} erro={erros.nome} obrigatorio>
<Entrada value={dados.nome} onChange={mudar("nome")} autoComplete="name" placeholder="Seu nome completo" />
</Campo>
<Campo rotulo="E-mail" icone={ICONES.email} erro={erros.email} obrigatorio>
<Entrada
type="email"
inputMode="email"
value={dados.email}
onChange={mudar("email")}
autoComplete="email"
placeholder="voce@empresa.com"
/>
</Campo>
<Campo rotulo="Telefone" icone={ICONES.telefone} erro={erros.telefone} obrigatorio>
<Entrada
mascara="(00) 00000-0000"
inputMode="tel"
value={dados.telefone}
onChange={mudar("telefone")}
autoComplete="tel"
placeholder="(00) 00000-0000"
/>
</Campo>
<Campo rotulo="CNPJ" icone={ICONES.empresa} erro={erros.cnpj} ajuda="Opcional.">
<Entrada
mascara="00.000.000/0000-00"
inputMode="numeric"
value={dados.cnpj}
onChange={mudar("cnpj")}
placeholder="00.000.000/0000-00"
/>
</Campo>
<div className="sm:col-span-2">
<Campo rotulo="Assunto" icone={ICONES.balao} erro={erros.assunto} obrigatorio>
<Selecao value={dados.assunto} onChange={mudar("assunto")} opcoes={ASSUNTOS} vazio="Selecione..." />
</Campo>
</div>
<div className="sm:col-span-2">
<Campo rotulo="Mensagem" erro={erros.mensagem} obrigatorio>
<AreaDeTexto value={dados.mensagem} onChange={mudar("mensagem")} placeholder="Conte como podemos ajudar" />
</Campo>
</div>
<div className="flex flex-col gap-4 sm:col-span-2 sm:flex-row sm:items-center">
<button type="submit" className={BOTAO}>
Enviar mensagem
</button>
{enviado && <Aviso tipo="sucesso">Mensagem enviada. Respondemos em até 1 dia útil.</Aviso>}
</div>
</form>
);
}
function ExemploCampo({
controle,
rotulo,
placeholder,
ajuda,
erro,
tamanho,
comIcone,
obrigatorio,
desativado,
}: {
controle: string;
rotulo: string;
placeholder: string;
ajuda: string;
erro: string;
tamanho: string;
comIcone: boolean;
obrigatorio: boolean;
desativado: boolean;
}) {
const [valor, setValor] = useState("");
const comum = { value: valor, onChange: setValor, placeholder, disabled: desativado };
const icone = controle === "EntradaDeSenha" ? ICONES.cadeado : ICONES.email;
return (
<div className="w-full max-w-sm">
<Campo
rotulo={rotulo}
icone={comIcone ? icone : undefined}
ajuda={ajuda || undefined}
erro={erro || undefined}
obrigatorio={obrigatorio}
tamanho={tamanho as TamanhoDoCampo}
>
{controle === "Selecao" ? (
<Selecao value={valor} onChange={setValor} disabled={desativado} opcoes={ASSUNTOS} vazio={placeholder} />
) : controle === "AreaDeTexto" ? (
<AreaDeTexto {...comum} />
) : controle === "EntradaDeSenha" ? (
<EntradaDeSenha {...comum} autoComplete="new-password" />
) : (
<Entrada {...comum} />
)}
</Campo>
</div>
);
}
const MASCARAS = [
{ rotulo: "Celular", mascara: "(00) 00000-0000", modo: "tel" as const },
{ rotulo: "CNPJ", mascara: "00.000.000/0000-00", modo: "numeric" as const },
{ rotulo: "CPF", mascara: "000.000.000-00", modo: "numeric" as const },
{ rotulo: "CEP", mascara: "00000-000", modo: "numeric" as const },
{ rotulo: "Data", mascara: "00/00/0000", modo: "numeric" as const },
{ rotulo: "Placa", mascara: "AAA-0*00", modo: "text" as const },
];
function ExemploMascaras() {
const [valores, setValores] = useState<Record<string, string>>({});
return (
<div className="grid w-full max-w-2xl gap-5 sm:grid-cols-2">
{MASCARAS.map((m) => (
<Campo key={m.rotulo} rotulo={m.rotulo} ajuda={`Máscara "${m.mascara}"`}>
<Entrada
mascara={m.mascara}
inputMode={m.modo}
placeholder={m.mascara.replace(/[0A*]/g, "_")}
value={valores[m.rotulo] ?? ""}
onChange={(v) => setValores((atual) => ({ ...atual, [m.rotulo]: v }))}
/>
</Campo>
))}
</div>
);
}
function ExemploSenha() {
const [senha, setSenha] = useState("");
const curta = senha.length > 0 && senha.length < 8;
return (
<div className="w-full max-w-sm">
<Campo
rotulo="Senha"
icone={ICONES.cadeado}
erro={curta ? "Use ao menos 8 caracteres." : undefined}
ajuda="Ao menos 8 caracteres. Use o olho para conferir o que digitou."
>
<EntradaDeSenha value={senha} onChange={setSenha} autoComplete="new-password" placeholder="Crie uma senha" />
</Campo>
</div>
);
}
function ExemploTamanhos() {
return (
<div className="flex w-full max-w-3xl flex-col gap-8">
{(["sm", "md", "lg"] as const).map((t) => (
<div key={t} className="grid items-end gap-4 sm:grid-cols-3">
<Campo rotulo={`Nome (${t})`} icone={ICONES.pessoa} tamanho={t}>
<Entrada placeholder="Seu nome" />
</Campo>
<Campo rotulo="Estado" tamanho={t}>
<Selecao
defaultValue="pe"
opcoes={[
{ valor: "pe", rotulo: "Pernambuco" },
{ valor: "sp", rotulo: "São Paulo" },
{ valor: "rs", rotulo: "Rio Grande do Sul" },
]}
/>
</Campo>
<Campo rotulo="Senha" tamanho={t}>
<EntradaDeSenha defaultValue="segredo123" />
</Campo>
</div>
))}
</div>
);
}
export default defineStories({
args: {
controle: "Entrada",
rotulo: "E-mail",
placeholder: "voce@empresa.com",
ajuda: "Usamos só para responder ao seu contato.",
erro: "",
tamanho: "md",
comIcone: true,
obrigatorio: false,
desativado: false,
},
controls: {
controle: {
type: "select",
label: "controle (filho do Campo)",
options: ["Entrada", "Selecao", "AreaDeTexto", "EntradaDeSenha"],
},
rotulo: { type: "text" },
placeholder: { type: "text" },
ajuda: { type: "text", label: "ajuda (vazio = sem)" },
erro: { type: "text", label: "erro (vazio = sem)" },
tamanho: { type: "select", options: ["sm", "md", "lg"] },
comIcone: { type: "boolean", label: "icone" },
obrigatorio: { type: "boolean" },
desativado: { type: "boolean", label: "disabled" },
},
render: (a) => <ExemploCampo key={a.controle} {...a} />,
stories: [
{ name: "Campo" },
{ name: "Formulário de contato", render: () => <FormularioDeContato /> },
{ name: "Com erro", args: { erro: "Informe um e-mail válido, como voce@empresa.com." } },
{ name: "Máscaras", render: () => <ExemploMascaras /> },
{ name: "Senha", render: () => <ExemploSenha /> },
{ name: "Tamanhos", render: () => <ExemploTamanhos /> },
],
});
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);
}