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 / keepRadio, 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-sm · pointer-coarse:text-base16px no celular e no dedo de propósito: abaixo disso o Safari dá zoom ao focar o campo. O piso do dedo mora no campo (pisoDoDedo) e vence a letra menor que uma tela pedir; a maior (um título em text-lg) fica.
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
Vizinho
LupaDeBusca · a lupa cresce até virar a pílula (300ms, curva do iOS) e empurra o vizinho; com palavra, fica aberta

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.
LupaDeBuscaA busca que fica fechada até ser pedida, onde buscar é raro ou a barra é apertada (Agenda, Funil, Números, Base de conhecimento). Dois modos: sobre (a pílula cresce por cima da barra, com o painel de achados embaixo, e a lupa fica de âncora: a Agenda) e empurra (a pílula toma o lugar da lupa e empurra os vizinhos: o filtro de uma lista). No empurra, com palavra digitada ela fica aberta: filtro ligado nunca some da vista. Fechar sempre limpa o termo; Esc e o X fecham e devolvem o foco à lupa. Onde buscar é o gesto principal (Cadastros) ou o assunto da janela (os pickers), o campo nasce aberto: SearchInput.
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 (keep.seq como key, 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.

Escolha de duas palavras

Uma regra que liga ou desliga, numa linha de configuração, se escreve em duas palavras: “Ligado / Desligado”, “Mostrar / Esconder”, “Volta / Fica”. A palavra escolhida É o estado. O interruptor fica nos dois lugares da seção abaixo.

O paciente escreve

O cartão vem pra cá quando o paciente manda mensagem.

Gravações pedidas: 0

ui/escolha.tsx · o texto da linha muda com o valor e vai pro leitor de tela pelo descricaoId
RegraPor quê
radiogroup, não tablistAba é LUGAR. Aqui não se navega: se escolhe um valor entre dois. ←/→ trocam, e o foco anda junto.
Clicar na palavra já escolhida não chama nadaNas etapas do funil, “Desligado” marcado quer dizer “não está aqui”; o reclique desligaria o acontecimento da clínica inteira. O onEscolher só roda quando o valor MUDA.
Nunca encolhe (shrink-0)Se a linha aperta, quem cede é o texto ao lado. Encolhida, a escolha esconderia uma das palavras, e a palavra é a informação.
No celular, embaixo do textoA linha é flex-col sm:flex-row: o texto ocupa a largura e a escolha desce, alinhada à esquerda.
Parada (desabilitado ou ocupado) nunca é disabledNão aceita clique nem seta, mas segue focável: um botão que vira disabled na mão de quem usa o teclado joga o foco no corpo da página. desabilitado apaga o grupo; ocupado (um gesto gravando) não, pra não piscar a cada gravação.

NotaEscolha, Checkbox ou menu

Escolha = UMA regra da clínica, numa linha que explica o que ela faz (Configurações da agenda, etapas do funil). Checkbox = marcar itens de uma lista na tela, cada um por si (dentro de um menu de filtro, a caixinha é o item do próprio menu, como no BotaoFiltrarVarios). Um valor entre muitos, num formulário = SelectField.

Interruptor

Em dois lugares: o “Aparece” de cada origem e motivo de perda (Clínica › Origens e Motivos de perda), uma linha de muitas iguais numa coluna; e a tela de Serviços (o painel do serviço e “Mostrar inativos” nos Filtros da vitrine), escolhida assim pelo founder. Fora deles, uma regra numa linha de configuração segue na escolha de duas palavras.
  • Instagram
  • Facebook
  • Outropadrão
ui/interruptor.tsx · desligar apaga o nome no lugar, sem tirar a linha · o que a regra não deixa mudar fica esmaecido, com o cadeado na frase (trancado)
RegraPor quê
button role="switch" com aria-checkedO leitor de tela anuncia ligado ou desligado. Sem frase ao lado, rotulo dá o nome (“Instagram aparece”).
parado, nunca disabled, enquanto gravaNão aceita clique, mas segue focável: disabled jogaria o foco no corpo da página na mão de quem usa o teclado.
Desligar não muda a linha de lugarEla apaga (nome em fg-subtle) e sai de onde a equipe escolhe; a ordem é a mesma ligada ou não.
O que não se desliga: trancadoEsmaecido, sem aceitar clique; o cadeado vai ao lado da palavra que diz por quê (“padrão”, “canal, não origem”). No dedo, a área de toque cresce pra 44 px sem mudar o desenho.

Ordem pela alça

Pôr uma linha em outra posição de uma lista: arrastar pela alça, ou, com a alça em foco, ↑ e ↓. O arrasto é em prévia: a lista se redesenha enquanto o ponteiro anda e só o SOLTAR grava. Arrastar por cinco posições é uma escrita, não cinco.
  • Novo
  • Contatado
  • Respondeu
  • Orçamento enviado
  • Consulta marcada
ui/alca-de-ordem.tsx · useArrastoNaLista + AlcaDeOrdem · Esc desiste do arrasto
RegraPor quê
A peça devolve índices; quem usa decideAs colunas de Cadastros param na coluna fixa, as etapas do funil param na faixa do significado. A alça não sabe o que a lista guarda.
A posição é medida nas linhas de verdadeUma linha que quebra em duas faria uma altura suposta pular itens.
O arrasto é ouvido na janelaA prévia move o nó da alça, e um nó movido perde a captura do ponteiro. Só o ponteiro que pegou conta; perder a janela ou pointercancel desistem.
O foco volta pra alça depois de ↑/↓Reordenar move o nó no DOM e o navegador solta o foco; sem a volta, o segundo ↓ não chegaria a lugar nenhum.
inerte = aria-disabled, nunca disabledCom uma gravação de outro tipo em curso a alça não aceita gesto, mas segue focável: disabled jogaria o foco no corpo da página no meio do teclado.

Armadilha já pagaLista longa no dedo pede outro caminho

Arrastar 19 itens dentro de um popover com o polegar é o gesto que ninguém acerta. Onde a lista é longa e vive num painel pequeno (as colunas de Cadastros), cada linha tem também os botões ↑ e ↓.

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: título e frase de ajuda à mostra, a roleta com uma plaquinha cinza no escolhido de cada coluna, a caixa de digitar embaixo e Voltar e OK na base. A escala é a enxuta: 34px por item, 5 visíveis, o mês em três letras e os nomes das colunas em letra normal (Dia, Mês, Ano, Hora, Min).
componentes reais · gire a roda, segure as setas, arraste a coluna, ou digite na caixa
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,
// numa linha inteira em cima de Voltar e OK (espremida em três, cortava):
<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.
Caixa de digitarUma caixa só com o valor inteiro em algarismo tabular escuro ("24/10/2026 10:30"), que acende no foco como qualquer campo; a parte em uso ganha um traço na cor da marca. Por dentro, um quadrinho por parte: inputmode numérico (é ela que abre o teclado de números no celular), pula sozinho pro próximo e a seta anda só aquela parte. Tocar na sobra (uma barra, a borda) vai pra parte mais perto. Fechar o modal devolve o foco ao campo.
EscolhidoPlaquinha cinza (ink-200, a cor do selecionado) com o número em negrito, uma por coluna, parada no centro: os itens passam por cima dela.
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, há um campo junto: no painel do serviço a régua tem passo de 10, e o número exato é o do campo grande em cima dela, que é também o que o Cobrar promete.

Faixas e marcas

Quando a POSIÇÃO do valor é a informação, a régua pinta o trilho por faixa (zonas: vermelho, âmbar, verde, sempre nos -soft chapados) e põe um traço no valor verdadeiro de cada referência (marcas, com nome e número embaixo). Com faixas não há parte percorrida: seriam duas leituras brigando no mesmo trilho. Nome de referência nunca vai na PONTA da régua: escrito ali ele mente sobre onde a referência está.

Seu preçoR$ 270,00
componente real · o polegar fica vermelho abaixo do mínimo
<Regua
  min={60}
  max={300}
  step={10}
  value={valor}
  onValue={setValor}
  aria-label="Simular preço"
  aria-valuetext={formatBRL(valor)}
  rotuloDoPolegar="você"
  zonas={[
    { ate: 110, tom: "negativo" },
    { ate: 170, tom: "atencao" },
    { ate: 300, tom: "positivo" },
  ]}
  marcas={[
    { valor: 110, rotulo: "mínimo", detalhe: "R$ 110,00" },
    { valor: 170, rotulo: "recomendado", detalhe: "R$ 170,00" },
  ]}
/>

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.