Componentes
Edição no lugar
O bloco inteiro, funcionando
Custo somado por atendimentoR$ 0,55
Três gestos, três comportamentos: o botão do cabeçalho ABRE a gaveta de criar (e vira “Fechar”); “Editar” faz a linha e a ficha cruzarem no mesmo eixo; “Excluir” arma a tarja com a consequência escrita.
As peças
| Peça | O que é |
|---|---|
| CrudCard | O card do bloco: título, descrição e o botão-toggle de adicionar (que some no vazio — ali o CTA do empty state assume). |
| CrudEmptyState | Ícone circular + título + texto + CTA. O CTA some enquanto o form de adicionar está aberto. |
| Gaveta | O vão animado. Duas gavetas irmãs cruzam: a da linha fecha enquanto a da ficha abre. |
| EditSurface | O tapete de edição: bg-ink-50 rounded-md p-4, com data-dim. É ele que liga o foco em camadas. |
| CrudRowShell | A linha: miolo livre + Editar + Excluir-com-tarja + alerta de erro do delete. |
| CrudFormActions | Salvar (primário) + Cancelar (ghost). Os dois dormem enquanto pending. |
| CrudTotalRow | O rodapé de total, com o valor em tabular à direita. |
| Prop | Tipo | Padrão | O que faz |
|---|---|---|---|
| CrudCard readOnly | true | — | Vista só-leitura (o catálogo de serviços pra quem não é admin): cabeçalho simples, sem toggle e sem data-dim-keep — nada ali edita, então não há véu pra furar. |
| CrudCard canAdd | boolean | true | Esconde o toggle de quem não pode editar (viewer). |
| CrudCard split | boolean | false | Cabeçalho em card próprio, separado do card da lista: o que explica o conceito e o que opera a lista param de disputar o mesmo retângulo. |
| CrudCard headerExtra | ReactNode | — | Conteúdo extra do cabeçalho (o orçamento da base de conhecimento) — fura o véu junto com o título. |
| Gaveta aberta | boolean | — | Recebe a prop em vez de ser montada pelo pai: a SAÍDA precisa de alguém segurando o conteúdo enquanto o vão fecha. |
| CrudRowShell deleteSentence | string | — | O que some, em uma frase — cada bloco sabe a consequência do seu. |
A anatomia de um bloco
<CrudCard title="Insumos" description="…" addLabel="Insumo"
adding={adding} onToggleAdding={…} empty={itens.length === 0}>
{/* 1. gaveta de CRIAR — o form tem Cancelar PRÓPRIO */}
<Gaveta aberta={adding}>
<EditSurface><Field … /><CrudFormActions … /></EditSurface>
</Gaveta>
{/* 2. vazio OU lista */}
{vazio ? <CrudEmptyState … /> : (
<div className="divide-y divide-border">
{itens.map((i) => (
<div key={i.id}>
<Gaveta aberta={editando !== i.id}><CrudRowShell …>{miolo}</CrudRowShell></Gaveta>
<Gaveta aberta={editando === i.id}>
<EditSurface><Field … /><CrudFormActions … /></EditSurface>
</Gaveta>
</div>
))}
{/* 3. total */}
<CrudTotalRow label="…">R$ …</CrudTotalRow>
</div>
)}
</CrudCard>Armadilha já pagaO form de CRIAR precisa de Cancelar próprio
{doc && onClose} deixava a clínica com ZERO documentos sem caminho de saída: o “Fechar” do cabeçalho só existe com canEdit && !empty, e o CTA do CrudEmptyState some quando adding é verdadeiro. E o “Fechar” cru engolia o texto calado — o guarda de rascunho global só intercepta <a href> e submit marcado.NotaQuem pergunta “descartar?” e quem não pergunta
O tapete e o foco em camadas
| Caso | O que usar | Por quê |
|---|---|---|
| Linha que vira ficha | EditSurface (traz data-dim) | O tapete só existe enquanto a edição existe. |
| Formulário SEMPRE aberto (capacidade, impostos, regras de agendamento, grade de horários) | data-dim + data-dim-solto, sem EditSurface | Tapete permanente mudaria o desenho da tela em REPOUSO. No solto, a elevação entra por ::before sangrando 12px: aparece e some sem empurrar um pixel. |
| Tapete de LEITURA (o conteúdo do documento aberto na lista) | bg-ink-50 sem data-dim | Marcá-lo faria a tela inteira recuar sem ninguém preenchendo nada. |
| Cabeçalho do card durante a edição | data-dim-keep | Fura a película e continua legível. |
O vocabulário completo — data-dim, data-dim-solto, data-field, data-dim-veil, data-dim-keep, data-dim-far — e as armadilhas de cada um estão em Foco em camadas.
Salvar
O slot da barra de tela
Se a tela tem PageBar, o Salvar de cada bloco sobe pra barra sozinho: o bloco publica um slot e a barra desenha o botão, com o estado (“Salvando…”, “Salvo ✓”) ao lado. Sem barra na tela, ele volta a desenhar o botão dentro do card — tela que ainda não migrou continua com jeito de salvar.
// O bloco não sabe onde o botão vai aparecer:
const { naBarra, ref } = useSaveSlot({ label: "Salvar", dirty, flash, pending, ativo });
// Painel de aba OCULTA não empurra o Salvar pra barra:
<SaveSlotGate open={abaAtiva}>…</SaveSlotGate>Armadilha já pagaSubmit programático lê o DOM, não o React
setX(novo); commit(); no mesmo tick envia o valor anterior: o requestSubmit() monta o FormData a partir do DOM, que ainda não recebeu o render novo — a tela mostra o novo, o banco fica com o velho, e o “Salvo ✓” acende igual. Imune: controle NATIVO nomeado. Quebra: valor que só o React escreve (<input type="hidden" value={estado}>) e o Checkbox do Radix. Cura: flushSync antes do commit.Armadilha já pagaCallback inline nas deps + componente que sobrevive
useCloseOnSuccess re-disparava a cada render do pai e, com a Gaveta segurando o form montado por 240ms, salvar um insumo e clicar no seguinte engolia o clique. A cura mora no hook (callback em ref + um disparo por identidade de estado) e vale pros sete blocos de uma vez.