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 / 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
| 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 · pointer-coarse:text-base | 16px 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. |
| 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. |
| LupaDeBusca | A 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. |
| 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 (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
O paciente escreve
O cartão vem pra cá quando o paciente manda mensagem.
Gravações pedidas: 0
| Regra | Por quê |
|---|---|
| radiogroup, não tablist | Aba é 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 nada | Nas 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 texto | A linha é flex-col sm:flex-row: o texto ocupa a largura e a escolha desce, alinhada à esquerda. |
| Parada (desabilitado ou ocupado) nunca é disabled | Nã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
SelectField.Interruptor
- Outropadrão
| Regra | Por quê |
|---|---|
| button role="switch" com aria-checked | O leitor de tela anuncia ligado ou desligado. Sem frase ao lado, rotulo dá o nome (“Instagram aparece”). |
| parado, nunca disabled, enquanto grava | Nã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 lugar | Ela 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: trancado | Esmaecido, 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
- Novo
- Contatado
- Respondeu
- Orçamento enviado
- Consulta marcada
| Regra | Por quê |
|---|---|
| A peça devolve índices; quem usa decide | As 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 verdade | Uma linha que quebra em duas faria uma altura suposta pular itens. |
| O arrasto é ouvido na janela | A 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 disabled | Com 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
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,
// 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
| 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. |
| Caixa de digitar | Uma 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. |
| Escolhido | Plaquinha 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. |
| 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
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á.
<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
// 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. |