Componentes
Campos de formulário
Field já costura rótulo, controle, dica e erro com as ligações de acessibilidade certas.Field — o que você usa
Esse nome aparece nas mensagens pros seus pacientes.
Número inválido. Use DDD + 9 dígitos.
Multi-linha é canto 16, não pílula.
<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).| Prop | Tipo | Padrão | O que faz |
|---|---|---|---|
| name | string | — | Obrigatório. É a chave do FormData e a chave que o campo pesca em errors. |
| label | ReactNode | — | Vira um <Label htmlFor> de verdade — clicar no texto foca o campo. |
| errors | Record<string, string[]> | — | O state.errors inteiro da action. O campo escolhe o próprio. |
| hint | ReactNode | — | Apoio entre o controle e o erro. Entra no aria-describedby junto com o erro. |
| after | ReactNode | — | Extra logo após o controle: um <datalist>, um apoio com cor própria. |
| className | string | — | Classe do WRAPPER (ex.: "sm:col-span-2"), não do controle. |
Armadilha já pagaErro de campo não é verdade permanente
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
| Detalhe | Valor | Por quê |
|---|---|---|
| Altura | h-9 · pointer-coarse:h-10 | 36px no mouse, 40px no dedo. |
| Forma | rounded-full | Campo de uma linha é coisa que se toca. |
| Padding | px-3.5 | A curva da pílula come espaço: menos que isso encosta o texto na borda. |
| Tamanho do texto | text-base md:text-sm | 16px no celular de propósito: abaixo disso o Safari dá zoom ao focar o campo. |
| Fundo | bg-surface | Nunca transparente — o campo precisa se separar do card. |
| Sombra | nenhuma | Borda 1px + halo de foco fazem o trabalho. |
| Seleção | selection:bg-primary selection:text-primary-foreground | O texto selecionado veste a marca. |
| Autofill | box-shadow interno + text-fill-color | O amarelo do Safari ignora background-color e acenderia no meio de um formulário escuro. |
Textarea, Select e Busca
valor: (vazio)
| Componente | Nota |
|---|---|
| SelectNative | No 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. |
| SearchInput | className é 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. |
| InputOTP | w-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). |
| Textarea | Cuidado 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
Armadilha já pagaO reset do React 19 não poupa o checkbox
<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
| Peça | O que é | Marcação |
|---|---|---|
| FieldError | O primeiro erro de um campo, em 12px destructive. | id={`${inputId}-error`} role="alert" |
| FormAlert | O 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 |
| PositiveBanner | A contraparte: estado bom, em positive-soft com escudo. | rounded-md px-3 py-2.5 |
| hint | Instruçã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
<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
| Gesto | O que acontece |
|---|---|
| Roda do mouse | Um entalhe anda EXATAMENTE um item, com deslize de 190ms. O wheel nativo voava três meses num giro. |
| Mouse parado na coluna | As setas das pontas aparecem; clique anda um, segurar repete. Somem em tela sem mouse. |
| Arrastar com o mouse | A coluna segue o ponteiro e assenta pela inércia ao soltar. No toque, quem manda é a rolagem nativa. |
| Teclado | Setas andam, Home/End vão às pontas, digitar salta ("2028" no ano, "ma" no mês). Enter confirma. |
| Pílula | Traço embaixo de cada número, área toda clicável, inputmode numérico — é ela que abre o teclado de números no celular. |
| OK | Descarrega a escolha que a rolagem ainda não subiu: quem confirma dentro dos 120ms do commit já escolheu. |
Armadilha já pagaPortal não é fora
useFecharJanela isso já está resolvido, com teste.Régua
<Regua
min={100}
max={500}
step={10}
value={valor}
onValue={setValor}
aria-label="Simular preço"
/>NotaRégua não é campo de valor
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
// 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ção | Regra |
|---|---|
| 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ário | Rodapé do bloco: Salvar (primária) + Cancelar (ghost). Em tela com barra, o Salvar SOBE pra barra sozinho. |
| Formulário sempre aberto | data-dim nu, sem EditSurface: sem tapete nem sombra, nem no foco; o card mantém o anel. |
| Formulário que abre numa linha | Gaveta + EditSurface: a linha VIRA a ficha. |