Pular para o conteúdo
Campos de formulário

Componentes

Campos de formulário

Campo de uma linha é pílula; multi-linha é canto 16. Todos focam verde com halo de 3px afastado 2px da borda, todos crescem um degrau no dedo, e nenhum deles é montado à mão numa tela nova: o Field já costura rótulo, controle, dica e erro com as ligações de acessibilidade certas.
ui/input.tsx · textarea · select-nativeui/field.tsxui/form-feedback.tsx

Field — o que você usa

Três variações do mesmo invólucro: Field (input), SelectField e TextareaField. Elas existem porque o fio entre rótulo, erro e dica era digitado 3× por campo e podia divergir em silêncio, quebrando o anúncio do erro no leitor de tela.

Esse nome aparece nas mensagens pros seus pacientes.

Multi-linha é canto 16, não pílula.

componentes reais · o erro do telefone some ao primeiro toque no campo
o uso normal — o resto é derivado
<Field
  name="telefone" // pesca errors["telefone"] sozinho
  label="Telefone"
  errors={state?.errors} // o objeto INTEIRO da server action
  hint="Com DDD. É por aqui que o paciente recebe a confirmação."
/>

// id só quando precisa diferir do name (o mesmo form aberto várias vezes
// na página, com useId por instância).
PropTipoPadrãoO que faz
namestring—Obrigatório. É a chave do FormData e a chave que o campo pesca em errors.
labelReactNode—Vira um <Label htmlFor> de verdade — clicar no texto foca o campo.
errorsRecord<string, string[]>—O state.errors inteiro da action. O campo escolhe o próprio.
hintReactNode—Apoio entre o controle e o erro. Entra no aria-describedby junto com o erro.
afterReactNode—Extra logo após o controle: um <datalist>, um apoio com cor própria.
classNamestring—Classe do WRAPPER (ex.: "sm:col-span-2"), não do controle.

Armadilha já pagaErro de campo não é verdade permanente

O erro que a action devolve é resposta a UM envio. Deixado aceso, ele acusa um campo que a pessoa já corrigiu — foi assim que “3533” apareceu marcado como preço inválido, com a mensagem do envio anterior. O Field apaga ao primeiro toque e reacende no submit do formulário. Não refaça isso por tela.

Armadilha já pagaA <form action> do React 19 RESETA os campos ao responder

Inclusive quando a resposta é um erro de validação: a pessoa perde o que digitou. Sintoma vivido: criar um procedimento virou labirinto — digita o nome, salva, ele pede a duração; preenche a duração, salva, ele pede o nome que o primeiro erro já tinha apagado. E uma senha errada em /entrar limpava o e-mail já digitado.

A cura mora em components/keep-values.ts: useKeepValues() fotografa os campos no onSubmit e devolve como defaultValue. Chame keep.clear() no caminho de SUCESSO de todo formulário que continua montado, senão o próximo envio reposta os valores anteriores.

Texto é o caso fácil. Para <select> e para o Checkbox do Radix o React restaura a partir do valor de MONTAGEM — o estado do React guarda a escolha, o DOM volta pra primeira opção, e o próximo envio grava o valor velho. A primeira opção de papéis é administrador: errar o e-mail e reenviar o convite convidava como ADMIN sem ninguém tocar no campo. Para esses vão keepSelected / keepChecked / keepAll, e a cura fica no CHAMADOR, nunca dentro do wrapper.

Casos com anatomia própria — upload de imagem, conjunto de checkboxes, contador de caracteres — montam as três peças à mão. O Field é pro caso comum, que é a esmagadora maioria.

Input

A pílula de 36px que veste quase tudo.
normal · com valor · inválido · desabilitado · dentro de toolbar (h-8)
DetalheValorPor quê
Alturah-9 · pointer-coarse:h-1036px no mouse, 40px no dedo.
Formarounded-fullCampo de uma linha é coisa que se toca.
Paddingpx-3.5A curva da pílula come espaço: menos que isso encosta o texto na borda.
Tamanho do textotext-base md:text-sm16px no celular de propósito: abaixo disso o Safari dá zoom ao focar o campo.
Fundobg-surfaceNunca transparente — o campo precisa se separar do card.
SombranenhumaBorda 1px + halo de foco fazem o trabalho.
Seleçãoselection:bg-primary selection:text-primary-foregroundO texto selecionado veste a marca.
Autofillbox-shadow interno + text-fill-colorO amarelo do Safari ignora background-color e acenderia no meio de um formulário escuro.

Textarea, Select e Busca

Mesmos tokens, três formas diferentes pela mesma razão: o que o controle faz decide a forma.
Textarea — rounded-xl: pílula cortaria a primeira linha
SelectNative — a pele do Input; no desktop abre a caixinha da casa, no celular a roleta do sistema
SearchInput — a lupa é decorativa (pointer-events-none) e o pl-9 abre espaço pra ela

valor: (vazio)

InputOTP: slot é item interno, dígito tabular, cursor falso piscando
ComponenteNota
SelectNativeNo desktop abre a caixinha da casa (o escolhido em verde) numa janela de 6 itens: lista maior rola por dentro com o acabamento da roleta, sem barra, encaixe item a item, névoa e seta na ponta que ainda esconde item (clicar anda um, segurar repete), e abre já rolada até o escolhido. Placeholder (“Selecione”) é <option hidden>: aparece em cinza no campo vazio e não entra na lista; vazio que é escolha (“Sem serviço”) fica sem hidden. No celular fica a roleta nativa, que o dedo conhece. Um select real escondido segue no DOM: FormData, keepSelected e o dirty do form funcionam igual ao nativo. Busca ou seleção múltipla continuam pedindo picker em diálogo.
SearchInputclassName é do INPUT; a LARGURA vai em wrapperClassName (flex-1, sm:max-w-xs). São dois alvos de estilo, então são dois nomes.
InputOTPw-8 até 420px de viewport e w-10 acima: 6 slots + gaps cabem nos ~238px úteis de um card em tela de 320px. Colar limpa não-dígitos (sem isso, um espaço copiado do e-mail descartava o código inteiro em silêncio).
TextareaCuidado com o \r: o transporte multipart normaliza toda quebra de linha pra \r\n. Quem depende de \n precisa normalizar no schema E no consumidor.

Checkbox

A única exceção ao vocabulário de forma: 6px de raio. Redondo pareceria radio — opção única em vez de escolha independente.
Radix Checkbox · marcado veste a marca · size-4 → size-5 no dedo

Armadilha já pagaO reset do React 19 não poupa o checkbox

Quando uma <form action> retorna, o React reseta os controles — e o Radix volta ao valor de MONTAGEM e ainda dispara onCheckedChange(false). A cura mora no CHAMADOR (keepChecked/keepAll em components/keep-values.ts), nunca dentro do wrapper: curar no ui/checkbox.tsx foi aplicado e revertido no mesmo dia, porque a restauração passa por um toggle intermediário e callbacks não-idempotentes apagavam dados.

Erro e apoio

Três peças, três papéis. Nenhuma delas alarma.
PeçaO que éMarcação
FieldErrorO primeiro erro de um campo, em 12px destructive.id={`${inputId}-error`} role="alert"
FormAlertO erro geral do formulário (state.message). Painel discreto em negative-soft — e ele rola até ficar visível quando surge.role="alert" · scroll-mt-20
PositiveBannerA contraparte: estado bom, em positive-soft com escudo.rounded-md px-3 py-2.5
hintInstrução do campo, 12px fg-subtle.id={`${inputId}-hint`}, dentro do aria-describedby

Sim

Telefone

Número inválido. Use DDD + 9 dígitos.

A mensagem diz o que fazer. O campo marca aria-invalid, e o texto está ligado a ele por aria-describedby.

Não

Telefone

Erro de validação: campo inválido (code 422).

Mensagem que descreve o sistema, não o problema. Quem lê não sabe o que corrigir — e o código HTTP não é assunto de quem marca consulta.

FormState.message significa erro: o FormAlert pinta de vermelho. Nunca devolva mensagem no caminho de sucesso — a copy de sucesso é da tela (um toast, um “Salvo ✓” na barra).

Data e hora — a roleta

Nenhum campo de data ou hora do app usa o seletor do navegador: aquele muda de cara em cada sistema e não é nosso. No lugar dele, sempre a mesma peça, sempre num modal centralizado com a tela atrás escurecida — roleta em cima, pílula digitável embaixo, Voltar e OK na base. A escala é a enxuta: 34px por item, 5 visíveis, e o mês em três letras.
componentes reais · gire a roda, segure as setas, arraste a coluna, ou digite na pílula
o uso normal
<SeletorHora rotulo="Começa às" valor={hhmm} onValor={setHhmm} />

// Expediente da clínica e grade do profissional: passo de 5.
// Não é gosto — é o CHECK operating_hours_passo_de_cinco no banco.
<SeletorHora rotulo="Abre às" passoDoMinuto={5} valor={hhmm} onValor={setHhmm} />

// Data em três colunas: dia, mês, ano. Quem precisa do dia da SEMANA usa o
// calendário grande da Agenda, que é outra ferramenta.
<SeletorDia rotulo="Dia" valor={ymd} onValor={setYmd} />

// Campo que aceita ficar vazio ganha a terceira saída no rodapé do modal:
<SeletorDia rotulo="Vale a partir de" textoDoLimpar="Valer agora" … />

// Onde a tela pede as DUAS coisas (remarcar, agendar da ficha do paciente):
// cinco colunas num modal só, data e hora separadas por uma divisória.
<SeletorDiaHora rotulo="Remarcar para" ymd={ymd} hhmm={hhmm} onValor={(d, h) => …} />

// O gatilho é um BUTTON, não um input: montado à mão dentro de um campo, ele
// precisa na mão dos três fios que o `Field` amarra sozinho — `id` pro
// `htmlFor` de um rótulo IRMÃO (rótulo envolvente já encaminha o clique
// sozinho), `descritoPor` com o erro e a dica, nessa ordem (o erro é o que
// precisa ser ouvido antes), e `invalido`, que pinta o vermelho por
// `data-` — `aria-invalid` não vale em `role="button"`. As três peças
// (`SeletorDia`, `SeletorHora`, `SeletorDiaHora`) aceitam os três.
<Label htmlFor="nascimento-gatilho">Nascimento</Label>
<SeletorDia
  rotulo="Nascimento"
  id="nascimento-gatilho"
  descritoPor="nascimento-error nascimento-hint"
  …
/>

// `abreEm`: onde a roleta abre com o campo VAZIO, quando "hoje" não é a
// pergunta. "Agendar retorno" conta os atalhos "em 7/30/90 dias" a partir do
// dia do COMPROMISSO — sem isto o calendário ao lado deles partia de hoje, e
// dois controles vizinhos tinham dois pontos de partida. Segue preso em
// min/max: abrir num dia que o próprio OK recusa não serve.
<SeletorDia rotulo="Ou escolher o dia" min={ymd} abreEm={ymd} … />

O que a peça faz que o navegador não faz

GestoO que acontece
Roda do mouseUm entalhe anda EXATAMENTE um item, com deslize de 190ms. O wheel nativo voava três meses num giro.
Mouse parado na colunaAs setas das pontas aparecem; clique anda um, segurar repete. Somem em tela sem mouse.
Arrastar com o mouseA coluna segue o ponteiro e assenta pela inércia ao soltar. No toque, quem manda é a rolagem nativa.
TecladoSetas andam, Home/End vão às pontas, digitar salta ("2028" no ano, "ma" no mês). Enter confirma.
PílulaTraço embaixo de cada número, área toda clicável, inputmode numérico — é ela que abre o teclado de números no celular.
OKDescarrega a escolha que a rolagem ainda não subiu: quem confirma dentro dos 120ms do commit já escolheu.

Armadilha já pagaPortal não é fora

A roleta abre num portal. Toda janela que fecha em clique fora precisa saber que portal não é fora — em useFecharJanela isso já está resolvido, com teste.

Régua

Escolher um número numa faixa contínua, quando a ordem de grandeza importa mais que o dígito exato: é o simulador de preço perguntando “e se eu cobrar…”. O controle é o input NATIVO, invisível por cima do desenho — ele é quem tem teclado, papel e valor pra leitor de tela, e arraste no toque. O que a casa substitui é só a pintura, que no nativo muda de cara em cada sistema operacional.
E se eu cobrar…R$ 250,00
R$ 100,00R$ 500,00
componente real · arraste, ou use as setas com o foco nele
<Regua
  min={100}
  max={500}
  step={10}
  value={valor}
  onValue={setValor}
  aria-label="Simular preço"
/>

NotaRégua não é campo de valor

Ela responde “mais ou menos quanto”, nunca “quanto exatamente”. Onde o dígito importa, o campo é Field com inputMode: a régua de preço do painel tem passo de 10 e o botão ao lado é que promete o número exato.

Layout de formulário

A grade, o ritmo e o que fica junto.
// Grade: uma coluna no celular, duas a partir de sm.
<div className="grid gap-6 sm:grid-cols-2">
  <Field name="nome" label="Nome" />
  <Field name="telefone" label="Telefone" />
  <TextareaField name="obs" label="Observação" className="sm:col-span-2" />
</div>

// Dentro do Field: space-y-2 entre rótulo, controle, dica e erro.
// Entre blocos de um formulário longo: space-y-phi-3.

O que anda junto

SituaçãoRegra
Campos que se completam (CEP, rua, número)Mesma linha quando couber; o CEP nunca sozinho numa linha inteira.
Campo largo (observação, endereço)sm:col-span-2 — não espremer texto longo em meia largura.
Ações do formulárioRodapé do bloco: Salvar (primária) + Cancelar (ghost). Em tela com barra, o Salvar SOBE pra barra sozinho.
Formulário sempre abertodata-dim nu, sem EditSurface: sem tapete nem sombra, nem no foco; o card mantém o anel.
Formulário que abre numa linhaGaveta + EditSurface: a linha VIRA a ficha.