Componentes
2. Form-field
seed-componentes.md v1.43 · §02seção 4 de 9802-form-field-campo-de-formulario-estavel-validado.md · MD5 e992bac7Título completo no canon: Form-field (campo de formulário) — estável · validado pelo Rafael em 2026-08-02 (v0.6)
Emenda v0.7 (2026-08-02, aprovada pelo Rafael — supersedes formais): (a) estados passam de 8 para 9 com a entrada do
warning(§2.4); (b) tokens de warning adicionados ao §2.5 com contraste medido; (c) contrato HTML ganha os atributos de teclado virtual (§2.6); (d) fonte do campo em toque = 16px (tokens v1.2). O que estas emendas substituem: "8 estados" onde citado → 9; tabela §2.6 anterior → tabela com as colunas mobile.
O que é este item: o componente-BASE anatômico de todo o Bloco 2. Define a estrutura que envolve QUALQUER controle de entrada — rótulo, texto auxiliar, mensagens, contador — mais estados, tokens, contrato HTML, arquitetura de máscaras, microcopy e acessibilidade. Os 12 itens seguintes do bloco (input de texto, busca, número, textarea, select, combobox, checkbox*, radio*, switch*, slider, date picker, upload) HERDAM esta spec e documentam apenas o que diverge. (*grupos de escolha usam
fieldset/legendno lugar delabel— divergência documentada nos próprios itens.)Base de evidência: 4 rodadas de pesquisa (2026-08-02, ~30 fontes primárias cruzadas): Carbon, GOV.UK + família, Material M1–M3, Polaris, Atlassian, Spectrum, USWDS, W3C WAI/WCAG 2.2, NN/g, Baymard, SAP Fiori, gov.br DS, web.dev (Google), Uber Base Web, Stripe, fintechs (Nubank/Revolut/Monzo/Wise), Konjević/Smashing, ecossistema BR de máscaras. Decisões B1–B6 aprovadas pelo Rafael em 2026-08-02 após sobreviverem às 4 rodadas.
2.1 Papel#
Estruturar a comunicação entre o sistema e a pessoa em QUALQUER entrada de dado: o que o sistema pede (rótulo), como preencher (ajuda), o que aconteceu (erro/sucesso) e quanto cabe (contador). O form-field não é um input — é a moldura que faz qualquer input ser compreensível, acessível e consistente em todos os produtos SEED (ERP, chat, site institucional, formulários de captação).
2.2 Anatomia — 5 slots#
(A) ┌ Rótulo ──────────────── (opcional) ┐ ← label + indicador de opcionalidade
(B) │ Texto auxiliar pré-digitação* │ ← só quando PRECISA ser lido antes (exceção B1)
(C) ┌────────────────────────────────────┐
│ [controle de entrada] │ ← input/select/textarea/etc (slot genérico)
└────────────────────────────────────┘
(D) │ ⓘ Texto auxiliar ou ⚠ Erro │ ← zona de mensagem (erro SUBSTITUI ajuda — B2)
(E) │ 120/500 │ ← contador (só quando há limite)
| Slot | Elemento | Regras |
|---|---|---|
| A | Rótulo (<label for>) |
Sempre visível, sempre no topo (nunca placeholder como rótulo; nunca flutuante; nunca à esquerda). Montserrat SemiBold 14px, --seed-field-label (13.86:1). Sufixo (opcional) em Regular, --seed-field-optional-text. Variante label-hidden (visually-hidden, permanece p/ leitor de tela) só quando o contexto torna o rótulo redundante — ex.: campo de busca com ícone e placeholder "Buscar…" — regra Polaris: usar com muito critério |
| B | Ajuda pré-digitação (exceção) | Entre rótulo e controle SOMENTE quando a instrução precisa ser lida ANTES de digitar (formato obrigatório complexo, decisão pré-preenchimento). Padrão é (D) |
| C | Controle | Slot genérico. Altura md 44px (alvo de toque WCAG, igual botão), sm 32px (densidade desktop), lg 52px. Radius --seed-radius-md. Borda 1px --seed-field-border (4.74:1 — é ela que identifica o controle, WCAG 1.4.11). Placeholder: DESENCORAJADO; quando usado, só exemplo de formato prefixado "Ex.:" — nunca instrução, nunca rótulo |
| D | Zona de mensagem | Ajuda: --seed-field-help-text (6.55:1 — acima do piso AA, na direção do 7:1 que o GOV.UK adotou após pesquisa). Erro: ícone ⚠ + texto --seed-field-error-text (7.18:1), prefixo "Erro:" visually-hidden p/ leitores de tela. Erro substitui a ajuda (B2); a mensagem de erro DEVE conter a instrução completa de correção (regra editorial que fecha o buraco da substituição). Sem reserva de altura: shift de 1 linha aceito (M3: substituição existe justamente p/ minimizar bump de layout) |
| E | Contador | Só quando há limite real. JetBrains Mono 12px (dado de medição = mono, regra dos tokens v1.1). Formato usados/limite. SUPERSEDE (v0.10): a política completa do contador vive no §6.3 (padrão GOV.UK) — o campo NUNCA trunca com maxlength duro; exceder o limite vira estado de ERRO com a contagem do excesso. A versão v0.6–v0.9 desta linha ('ao exceder, vira mensagem de erro' com maxlength no exemplo) fica substituída: o maxlength duro truncava colagem silenciosamente — perda de dado pega na pesquisa do textarea |
Espaçamento vertical (mapeado da referência 8/16dp do Material para a escala SEED): rótulo→controle sp-2 (8px) · controle→mensagem sp-2 (8px) · campo→campo sp-6 (24px) confortável / sp-4 (16px) denso (ERP).
2.3 As 6 decisões estruturais (aprovadas 2026-08-02)#
| # | Decisão | Porquê (placar de fontes) | Descartado |
|---|---|---|---|
| B1 | Ajuda ABAIXO do controle; exceção: instrução pré-digitação vai acima | 6×1 (Carbon, Material, Polaris, Spectrum, Atlassian, MUI vs GOV.UK); padrão da stack shadcn; label topo lido 28% mais rápido que lateral (Baymard) | Hint sempre acima (GOV.UK — contexto one-thing-per-page de governo, não ERP denso) |
| B2 | Erro SUBSTITUI a ajuda + regra editorial (erro contém a correção completa) | 5×1 (Carbon, M1/M3, Atlassian, Spectrum, uxpatterns vs GOV.UK); M3: substituir evita empurrar o layout | Empilhar ajuda+erro (dobra a altura no pior momento); reservar altura fixa (desperdiça vertical em ERP) |
| B3 | Produto (ERP/chat): marcar SÓ (opcional). Formulários públicos de conversão: marcar AMBOS (* + (opcional)) |
Produto: Polaris (proíbe asterisco), Carbon, GOV.UK, Uber ("marque só os menos comuns"). Público: Baymard mediu 32% de falha marcando só opcionais no e-commerce; só 14% marcam ambos | Regra única para os dois contextos (a indústria não tem consenso único — tem consenso POR contexto); asterisco no ERP (ruído: quase tudo é obrigatório) |
| B4 | Validação onBlur com reward-early/punish-late + error summary no submit | Formulação operacional Polaris: valida quando foco sai E há ≥1 caractere; campo em erro revalida a cada tecla; obrigatório intocado só marca no submit. Refinamento Fiori p/ ERP: entrada massiva agrega erros em registro form-level. NN/g: correção imediata com o campo fresco na memória | Validar só no submit (GOV.UK — governo); validar enquanto digita desde a 1ª tecla (validação prematura, irrita — Baymard) |
| B5 | Arquitetura de máscara: REGISTRY EXTENSÍVEL, valor canônico sem formatação, telefone em E.164 | Stripe: tolerante na entrada, estrito no armazenamento; Uber: seletor de país + número (2 inputs, menos ambíguo); libphonenumber/E.164 = padrão da indústria; Baymard: 64% não usam máscara localizada e deviam. Crítica do Rafael derrubou o enum fixo: 0800 e clientes EUA não cabiam | Enum fechado de máscaras BR (proposta da rodada 3 — fecharia a porta p/ 0800, EUA e máscaras futuras) |
| B6 | Estado success EXISTE, com parcimônia: só onde a confirmação carrega informação real | Atlassian/Spectrum/Stripe têm; Stripe marca válido e mede ganho de confiança; Uber alerta contra marcar prematuramente | Sem success (perde o ganho em campos críticos: e-mail, documento, disponibilidade); success universal (ruído verde decorativo — GOV.UK desaconselha com razão) |
2.4 Estados — 9 (os 6 obrigatórios do §0 + readonly, success e warning¹)#
¹ warning adicionado na emenda v0.7 (era 8 na v0.6 — supersede formal).
| Estado | Sinais (light) | Regras |
|---|---|---|
| default | borda --seed-field-border (4.74:1) |
— |
| hover | borda --seed-field-border-hover |
transição 100ms produtiva |
| focus | --seed-focus-ring (anel, NÃO engrossar borda) |
mecanismo de foco ≠ mecanismo de erro (GOV.UK removeu borda grossa no erro por confundir com foco) |
| filled | valor em --seed-field-value (13.86:1) |
valor ≠ placeholder tem que ser óbvio (por isso placeholder é 4.74 e valor 13.86) |
| error | borda --seed-field-border-error (5.19:1) + ícone ⚠ + mensagem (7.18:1) + aria-invalid |
NUNCA só cor (WCAG 1.4.1): são 3 sinais. Erro nunca aparece antes de interação real (B4) |
| success | borda --seed-field-border-success (4.60:1) + ícone ✓ + mensagem opcional |
só onde confirma algo real (B6) |
| warning (v0.7) | borda --seed-field-border-warning (4.82:1) + ícone ▲ + mensagem (6.68:1) |
Guarda-corpo duro: SÓ para plausibilidade — dado válido em formato, mas incomum ("consumo 10× acima da sua média — confira"). NUNCA para obrigatoriedade, NUNCA bloqueia submit. Sem o guarda-corpo, warning banaliza e treina a ignorar avisos (por isso o GOV.UK nem o tem). Origem: SAP Fiori/Carbon (ERP) |
| disabled | fundo surface-sunken + texto text-disabled + cursor:not-allowed |
USO RESTRITO (USWDS: contraste baixo, invisível p/ teclado e leitor de tela) — preferir campo habilitado com validação, ou readonly. Nunca esconder informação necessária num campo disabled |
| readonly | fundo surface-sunken, SEM borda de controle, texto pleno (12.75:1), focável, copiável |
estado de primeira classe (Fiori/USWDS/Polaris): exibe dado não-editável NESTE contexto (ex.: UC vinda do cadastro). Readonly ≠ disabled: readonly participa do fluxo (tab, cópia, leitor de tela); não recebe estados de validação (Fiori) |
loading (9º, condicional): campos com operação assíncrona (CEP consultando ViaCEP, combobox buscando) mostram spinner 16px no slot direito do controle + aria-busy — herda a regra do botão (sem layout shift).
2.5 Tokens de componente (camada 3) — par light/dark obrigatório, contrastes medidos programaticamente (2026-08-02)#
/* Estrutura */
--seed-field-height-sm: 32px; --seed-field-height-md: 44px; --seed-field-height-lg: 52px;
--seed-field-radius: var(--seed-radius-md);
--seed-field-gap: var(--seed-sp-2); /* rótulo→controle e controle→mensagem */
--seed-field-stack-gap: var(--seed-sp-6); /* campo→campo; sp-4 no denso */
/* Cor — light (medições sobre branco; campo assenta em superfície branca) */
--seed-field-bg: var(--seed-surface-page); /* branco */
--seed-field-label: var(--seed-cinza-900); /* 13.86:1 */
--seed-field-value: var(--seed-cinza-900); /* 13.86:1 */
--seed-field-placeholder: var(--seed-cinza-600); /* 4.74:1 — AA e distinto do valor */
--seed-field-optional-text: var(--seed-cinza-600); /* 4.74:1 */
--seed-field-help-text: var(--seed-cinza-700); /* 6.55:1 — direção GOV.UK 7:1 */
--seed-field-border: var(--seed-cinza-600); /* 4.74:1 ≥3:1 (WCAG 1.4.11) */
--seed-field-border-hover: var(--seed-cinza-700);
--seed-field-error-text: var(--seed-vermelho-700); /* 7.18:1 */
--seed-field-border-error: var(--seed-vermelho-600); /* 5.19:1 */
--seed-field-success-text: var(--seed-turquesa-700); /* 6.32:1 */
--seed-field-border-success: var(--seed-turquesa-600); /* 4.60:1 */
--seed-field-readonly-bg: var(--seed-surface-sunken); /* texto 12.75:1 sobre cinza-50 */
--seed-field-warning-text: var(--seed-dourado-700); /* 6.68:1 (v0.7) */
--seed-field-border-warning: var(--seed-dourado-600); /* 4.82:1 (v0.7) */
/* Par dark — REGRA v0.5: token que referencia primitivo DECLARA o par (primitivo não troca de modo).
Medições: rótulo/mensagens sobre dark-default #141D23; conteúdo do campo sobre dark-raised #1D272D */
[data-theme="dark"] {
--seed-field-bg: var(--seed-surface-raised); /* #1D272D */
--seed-field-label: var(--seed-cinza-100); /* 14.16:1 */
--seed-field-value: var(--seed-cinza-100); /* 12.61:1 */
--seed-field-placeholder: var(--seed-cinza-400); /* 6.01:1 */
--seed-field-optional-text: var(--seed-cinza-400);
--seed-field-help-text: var(--seed-cinza-300); /* 8.92:1 */
--seed-field-border: var(--seed-cinza-500); /* 4.50:1 (600 sumia; 400 gritava em massa de campos do ERP) */
--seed-field-border-hover: var(--seed-cinza-400); /* 6.01:1 */
--seed-field-error-text: var(--seed-vermelho-300); /* 8.49:1 */
--seed-field-border-error: var(--seed-vermelho-400); /* 5.52:1 */
--seed-field-success-text: var(--seed-turquesa-300); /* 9.32:1 */
--seed-field-border-success: var(--seed-turquesa-300); /* 8.31:1 */
--seed-field-readonly-bg: var(--seed-surface-sunken); /* texto = field-value cinza-100 #E3EBF0 sobre #141D23: 14.16:1 (v0.13 — supersede do comentário 9.72:1, que media cinza-300 sobre a página, par que o readonly não renderiza) */
--seed-field-warning-text: var(--seed-dourado-300); /* 9.22:1 (v0.7) */
--seed-field-border-warning: var(--seed-dourado-300); /* 8.21:1 (v0.7) */
}
2.6 Contrato HTML do campo (a camada que os DS visuais não cobrem)#
Todo campo declara o trio type + inputmode + autocomplete + name/id ESTÁVEIS. Serve três frentes de uma vez: teclado mobile correto, autofill do navegador (conversão) e legibilidade por máquinas — agentes de IA e crawlers entendem o formulário pelos atributos semânticos (a frente "IA/SEO" do site institucional).
| Dado | type | inputmode | autocomplete | enterkeyhint² | autocapitalize/autocorrect/spellcheck² | Nota |
|---|---|---|---|---|---|---|
| Nome | text | — | name | next | on / off / off | |
| next | off / off / off | Android "corrige" e-mail p/ palavra | ||||
| Telefone | tel | tel | tel | next | off / off / off | armazena E.164 (§2.7) |
| CPF/CNPJ/CEP/UC | text | numeric | — (cpf não tem token oficial; cep usa postal-code) | next | off / off / off | NUNCA type=number (só p/ quantidade incremental — web.dev); number quebra máscara, zeros à esquerda e leitores |
| Endereço | text | — | street-address / address-line1… | next | on / on / on | |
| Valores (R$, kWh) | text | decimal | — | next | off / off / off | JetBrains Mono no valor (dado de medição) |
| Senha | password | — | current-password / new-password | done | off / off / off | norma §3.5 |
² Colunas adicionadas na emenda v0.7 (mobile): enterkeyhint rotula a tecla Enter do teclado virtual ("próximo" no meio do fluxo, "concluído"/done no último campo, search na busca); o trio autocapitalize/autocorrect/spellcheck desligado em identificadores impede o Android de "corrigir" UC/códigos para palavras. SC 3.3.7 Redundant Entry (WCAG 2.2): dado já fornecido no mesmo fluxo não pode ser exigido de novo por transcrição — auto-popular ou oferecer seleção (mata o "confirme seu e-mail").
Regras: name/id nunca gerados aleatoriamente por render (autofill exige estabilidade — web.dev) · formulário real <form> com botão de submit · botão de submit NUNCA desabilitado por campos vazios (Atlassian): a validação e o error summary dizem o que falta.
2.6b Grupos de controles — fieldset + legend (emenda v0.12)#
Quando o "campo" é um GRUPO (radios §9, checkboxes §10, grupos de switch §8), o slot A vira <legend> dentro de <fieldset>: a pergunta é a legend; ajuda e erro do form-field continuam valendo via aria-describedby. Regra declarada UMA vez aqui — as specs da família citam. (GOV.UK/NHS/USWDS unânimes; a legend é lida junto de cada opção pelos leitores, dando contexto sem repetição.)
2.7 Máscaras — registry extensível (decisão B5)#
Três princípios arquiteturais:
- Máscara é apresentação. O valor canônico armazenado NÃO tem formatação: CPF = 11 dígitos, CEP = 8, telefone = E.164 (
+5533999999999,+14155552671). A exibição formata; o dado viaja limpo (bancos, APIs, SMS esperam E.164 — padrão libphonenumber/Google). - Tolerante na entrada, estrita no armazenamento (Stripe). Colar
033 99999-9999,(33)999999999ou33999999999→ tudo aceito e normalizado. Nunca rejeitar por pontuação. - Registry aberto. Máscaras são registráveis por nome (
registerMask("placa-mercosul", …)) — produto novo adiciona máscara sem alterar o componente. NÃO é enum fechado (alternativa descartada na rodada 3 pela crítica do Rafael: 0800 e clientes EUA não cabiam).
Registry inicial:
| Máscara | Exibição | Canônico | Notas |
|---|---|---|---|
cpf |
000.000.000-00 | 11 díg. | valida dígitos verificadores |
cnpj |
00.000.000/0000-00 | 14 díg. | idem |
cpf-cnpj |
dinâmica pelo comprimento | 11 ou 14 | campo único B2C+B2B (padrão BR) |
cep |
00000-000 | 8 díg. | + consulta ViaCEP (loading no campo; falha da API NUNCA bloqueia — preenchimento manual sempre possível) |
tel-br |
(00) 0000-0000 / (00) 9 0000-0000 | E.164 +55… | detecta fixo/celular pelo comprimento |
tel-nao-geografico |
0800 000 0000 | dígitos puros | 0800/0300/0500/4004 — sem DDD, sem E.164 de assinante, categoria própria |
tel-internacional |
seletor de país + número nacional | E.164 | padrão Uber (2 inputs); default Brasil; cobre EUA e qualquer país; trocar país MANTÉM o número digitado (Evil Martians); validação libphonenumber |
moeda |
R$ 1.234,56 (ou US$ por locale) | decimal | |
data |
DD/MM/AAAA | ISO 8601 | tolera colar 01012026 |
Armadilhas de implementação (documentadas pela comunidade BR — bugs reais a evitar): maxLength conta os caracteres DA MÁSCARA (CPF = 14, não 11) · reposicionar o cursor após formatação programática (o value re-setado joga o cursor pro fim) · autofill do navegador entrega valor sem máscara → normalizar no change, não só no keypress.
2.8 Microcopy — tom SEED (vocabulário PT-BR alinhado ao gov.br DS: rótulo, texto auxiliar, mensagem)#
- Rótulo: substantivo curto, sentence case, sem dois-pontos. "Unidade consumidora", "E-mail", "CNPJ". Nunca instrução no rótulo.
- Texto auxiliar: 1 linha, diz FORMATO ou PORQUÊ. "Somente números." · "Está na sua fatura de energia, no canto superior." Nunca indica obrigatoriedade (regra Uber: esse espaço é de formato/erro).
- Erro = o que houve + como corrigir, na voz SEED (direto, acolhedor, sem culpar): ✅ "CPF incompleto — digite os 11 números." · "CEP não encontrado — confira ou preencha o endereço manualmente." | ❌ "Erro no campo." · "Entrada inválida." · "Você digitou errado." Específico > genérico (Baymard: "CEP curto demais" > "Inválido").
- Dado pessoal explica o porquê (LGPD + prática fintech): texto auxiliar do CPF: "Usamos seu CPF apenas para emitir a proposta." Coleta sem porquê visível é atrito e risco.
(opcional)literal em minúsculas no produto; formulário público de conversão usa*+ legenda "Campos com * são obrigatórios" no topo (B3).
2.9 Acessibilidade#
<label for> sempre (nunca div-rotulada) · aria-describedby na ordem erro ANTES da ajuda ("err-id help-id" — AUI: o erro é anunciado primeiro) · aria-invalid só após validação real, NUNCA no estado inicial (W3C ARIA21), e SEMPRE com valor explícito "true" — nunca toggleAttribute, que grava o atributo com valor vazio: [aria-invalid="true"] do CSS não casa e a borda de erro some (bug flagrado na validação executada de 2026-08-03) · mensagem de erro com prefixo "Erro:" visually-hidden · erro dinâmico anunciado (região aria-live="assertive" ou role="alert") · ícones de estado com par textual (1.4.1) · foco: anel, nunca borda engrossada · contraste: TODOS os pares da §2.5 medidos ≥4.5:1 texto e ≥3:1 não-texto · zoom 400%: layout de coluna única resiste · toque ≥44px no md.
2.10 Recuperação de erro no formulário (nível form — regras que o form-field expõe)#
- NUNCA limpar campos após erro (Baymard/Stripe: re-digitar é gatilho de abandono nº1).
- Error summary no submit para formulário com 3+ campos: bloco no topo com título instrucional + lista de links-âncora para cada erro; foco move pro summary (GOV.UK); autoscroll até o primeiro campo em erro (Baymard).
- Erro também no
<title>da página quando o submit recarrega (USWDS — leitores de tela anunciam antes de tudo). - ERP/entrada massiva (Fiori): validação por campo no blur (B4) + agregado de erros form-level com contador; teclado flui sem interrupção. Detalhe: ponte com o Bloco 3 (alert/banner) — o componente de summary nasce lá.
2.11 Código — HTML/CSS#
<div class="seed-field">
<label class="seed-field__label" for="cpf">
CPF <span class="seed-field__optional">(opcional)</span>
</label>
<input class="seed-field__control" id="cpf" name="cpf" type="text"
inputmode="numeric" maxlength="14" placeholder="Ex.: 000.000.000-00"
aria-describedby="cpf-err cpf-help">
<p class="seed-field__help" id="cpf-help">Usamos seu CPF apenas para emitir a proposta.</p>
<p class="seed-field__error" id="cpf-err" hidden>
<span class="seed-visually-hidden">Erro:</span>
<svg class="seed-field__error-icon" aria-hidden="true" viewBox="0 0 16 16" width="16" height="16"><path fill="currentColor" d="M8 1a7 7 0 1 0 0 14A7 7 0 0 0 8 1Zm-.9 3.5h1.8v5h-1.8v-5Zm.9 8.4a1.1 1.1 0 1 1 0-2.2 1.1 1.1 0 0 1 0 2.2Z"/></svg>
CPF incompleto — digite os 11 números.
</p>
</div>
[hidden]{display:none!important} /* ARMADILHA (bug real pego pelo Rafael na validação do preview v0.7, 2026-08-02): classes de mensagem usam display:flex, que vence o display:none que o navegador aplica ao atributo `hidden` — sem esta regra, erro e sucesso aparecem SEMPRE. No React o problema não existe (renderização condicional), mas todo consumo HTML/CSS puro precisa desta linha. */
.seed-field{display:flex;flex-direction:column;gap:var(--seed-field-gap)}
.seed-field__label{font-family:var(--seed-font-sans);font-weight:var(--seed-fw-semibold);
font-size:14px;color:var(--seed-field-label)}
.seed-field__optional{font-weight:400;color:var(--seed-field-optional-text)}
.seed-field__control{height:var(--seed-field-height-md);padding:0 12px;
font-family:var(--seed-font-sans);font-size:14px;color:var(--seed-field-value);
background:var(--seed-field-bg);border:1px solid var(--seed-field-border);
border-radius:var(--seed-field-radius);
transition:border-color var(--seed-dur-productive-fast) var(--seed-ease-productive)}
.seed-field__control::placeholder{color:var(--seed-field-placeholder)}
.seed-field__control:hover:not(:disabled):not([readonly]){border-color:var(--seed-field-border-hover)}
.seed-field__control:focus-visible{outline:none;box-shadow:var(--seed-focus-ring)}
.seed-field__control[aria-invalid="true"]{border-color:var(--seed-field-border-error)}
.seed-field__control[data-valid="true"]{border-color:var(--seed-field-border-success)}
.seed-field__control:disabled{background:var(--seed-surface-sunken);
color:var(--seed-text-disabled);cursor:not-allowed}
.seed-field__control[readonly]{background:var(--seed-field-readonly-bg);border-color:transparent}
.seed-field__help{font-size:12px;color:var(--seed-field-help-text);margin:0}
.seed-field__error{display:flex;align-items:center;gap:6px;font-size:12px;
font-weight:var(--seed-fw-semibold);color:var(--seed-field-error-text);margin:0}
.seed-field__counter{font-family:var(--seed-font-mono);font-size:12px;
color:var(--seed-field-help-text);align-self:flex-end}
.seed-visually-hidden{position:absolute;width:1px;height:1px;margin:-1px;
padding:0;overflow:hidden;clip:rect(0 0 0 0);white-space:nowrap;border:0}
2.12 Código — React/TypeScript (padrão shadcn de composição; RHF pluga por cima)#
import * as React from "react";
import { cn } from "@/lib/utils";
import { CircleAlert, CircleCheck } from "lucide-react";
type FieldCtx = { id: string; helpId: string; errId: string;
invalid?: boolean; valid?: boolean };
const FieldContext = React.createContext<FieldCtx | null>(null);
const useField = () => {
const ctx = React.useContext(FieldContext);
if (!ctx) throw new Error("Componentes Field* devem viver dentro de <FormField>");
return ctx;
};
export function FormField({ id, invalid, valid, className, children }:
React.PropsWithChildren<{ id: string; invalid?: boolean; valid?: boolean; className?: string }>) {
const ctx = { id, helpId: `${id}-help`, errId: `${id}-err`, invalid, valid };
return (
<FieldContext.Provider value={ctx}>
<div className={cn("flex flex-col gap-2", className)}>{children}</div>
</FieldContext.Provider>
);
}
export function FieldLabel({ optional, children }:
React.PropsWithChildren<{ optional?: boolean }>) {
const { id } = useField();
return (
<label htmlFor={id} className="text-sm font-semibold text-[var(--seed-field-label)]">
{children}{" "}
{optional && <span className="font-normal text-[var(--seed-field-optional-text)]">(opcional)</span>}
</label>
);
}
export function FieldControl(props: React.InputHTMLAttributes<HTMLInputElement>) {
const { id, helpId, errId, invalid, valid } = useField();
return (
<input
id={id}
aria-invalid={invalid || undefined}
aria-describedby={cn(invalid && errId, helpId) || undefined} /* erro ANTES da ajuda */
data-valid={valid || undefined}
className={cn(
"h-11 rounded-md border bg-[var(--seed-field-bg)] px-3 text-sm",
"text-[var(--seed-field-value)] placeholder:text-[var(--seed-field-placeholder)]",
"border-[var(--seed-field-border)] hover:border-[var(--seed-field-border-hover)]",
"transition-colors duration-100 focus-visible:outline-none",
"focus-visible:ring-2 focus-visible:ring-[var(--seed-border-focus)] focus-visible:ring-offset-2",
"disabled:cursor-not-allowed disabled:bg-[var(--seed-surface-sunken)] disabled:text-[var(--seed-text-disabled)]",
"read-only:border-transparent read-only:bg-[var(--seed-field-readonly-bg)]",
invalid && "border-[var(--seed-field-border-error)]",
valid && "border-[var(--seed-field-border-success)]",
)}
{...props}
/>
);
}
export function FieldHelp({ children }: React.PropsWithChildren) {
const { helpId, invalid } = useField();
if (invalid) return null; /* B2: erro substitui a ajuda */
return <p id={helpId} className="text-xs text-[var(--seed-field-help-text)]">{children}</p>;
}
export function FieldError({ children }: React.PropsWithChildren) {
const { errId, invalid } = useField();
if (!invalid) return null;
return (
<p id={errId} role="alert"
className="flex items-center gap-1.5 text-xs font-semibold text-[var(--seed-field-error-text)]">
<span className="sr-only">Erro:</span>
<CircleAlert className="size-4 shrink-0" aria-hidden />
{children}
</p>
);
}
export function FieldSuccess({ children }: React.PropsWithChildren) {
const { valid } = useField();
if (!valid) return null;
return (
<p className="flex items-center gap-1.5 text-xs font-semibold text-[var(--seed-field-success-text)]">
<CircleCheck className="size-4 shrink-0" aria-hidden />
{children}
</p>
);
}
Hook de validação B4 (reward-early/punish-late) e utilitários de máscara (registry + libphonenumber-js) são entregues no item "input de texto" deste bloco, que é o primeiro consumidor concreto.
2.13 Decisões deste item (além de B1–B6, com alternativas descartadas)#
| Decisão | Porquê | Descartado |
|---|---|---|
| Form-field como componente-base do bloco | 13 itens compartilham a mesma moldura; especificar 1 vez evita 13 variações | Especificar label/ajuda/erro dentro de cada item (deriva incoerência) |
| Ajuda em cinza-700 (6.55:1), não cinza-600 | GOV.UK escureceu o hint p/ 7:1 após usuários não lerem; 700 é o stop SEED mais próximo dessa direção mantendo hierarquia c/ o valor | cinza-600 (4.74 — passa AA mas repete o erro que o GOV.UK corrigiu) |
| Erro texto em vermelho-700 (7.18:1), borda em vermelho-600 | Texto de 12px pede folga acima do piso; borda só precisa 3:1 e o 600 casa com o botão destructive | Tudo em vermelho-500 (4.30:1 medido — serve p/ borda ≥3, mas REPROVA texto normal <4.5) |
| Borda dark = cinza-500 (4.50:1) | cinza-600 somia no dark; cinza-400 em massa de campos do ERP grita (outline de botão usa 400 porque botão é ação — campo é massa) | Reusar o token do botão outline (papéis diferentes: ação pontual vs grade de campos) |
| Foco = anel; erro = cor de borda + ícone + texto | Mecanismos distintos por estado (GOV.UK removeu borda grossa do erro por colidir com foco) | Engrossar borda no erro (colisão de sinal) |
| Contador em JetBrains Mono | "usados/limite" é dado de medição — papel exclusivo da mono nos tokens v1.1 | Montserrat (números dançam sem tabular) |
| ViaCEP com fallback manual sempre | API externa cai; endereço nunca pode ficar refém | CEP obrigatório via API (bloqueio real de cadastro) |
2.14 Aplicação dos 6 testes (§1.12)#
- Container 3:1: borda default 4.74:1 (light) / 4.50:1 (dark); erro 5.19/5.52; success 4.60/8.31 — MEDIDOS, tabela §2.5. ✅
- Escada de mecanismos: estados não diferem só por tom — error = borda+ícone+mensagem; success = borda+ícone; readonly = REMOVE a borda e muda o fundo; disabled = fundo+cursor+texto; focus = anel. ✅
- Grayscale: ícones ⚠/✓ e a presença/ausência de borda carregam os estados sem matiz — faixa cinza pré-renderizada no preview. ✅
- Teste do par: campo default × campo em erro lado a lado no preview — distinguíveis a 1s de distância de braço. ✅
- Regra do um: adaptação p/ formulário — UMA mensagem por campo por vez (B2 + regra Uber de nunca empilhar validações). ✅
- Estado atual: campo em erro/sucesso/readonly identificável DE RELANCE por quem chega agora, sem interagir (3 sinais no erro; fundo+ausência de borda no readonly). ✅
Também cita o §2: tela-autenticacao, tela-busca.