Loading padronizado em formulários — Button, FormFieldSkeleton, TriggerStatus
Antes destes componentes, cada formulário resolvia o estado de "carregando" do seu jeito: spinner montado à mão dentro do botão, texto "Carregando..." trocado no placeholder do Select, ou simplesmente disabled sem nenhum feedback visual. Os três componentes abaixo cobrem os casos mais comuns — vivem em packages/ui/src/shadcn/.
Button com loading
O Button aceita uma prop loading opcional: quando true, mostra um spinner (@kit/ui/spinner) antes do conteúdo e desabilita o botão automaticamente — sem precisar do {isPending && <Spinner/>} manual.
import { Button } from '@kit/ui/button';
<Button type="submit" loading={isPending}>
Salvar alterações
</Button>
| Prop | Tipo | Padrão | Descrição |
|---|---|---|---|
loading | boolean | false | Mostra o spinner e desabilita o botão. Combina com disabled via OR — o botão fica desabilitado se qualquer um dos dois for true. |
Não passe um ícone próprio como filho leading do botão quando usar loading — o spinner já ocupa essa posição, e os dois juntos duplicariam o espaço.
FormFieldSkeleton
Exportado junto dos outros primitivos de formulário em @kit/ui/form. Substitui um campo inteiro (label + controle) por um efeito de esqueleto (Skeleton) enquanto os dados de que ele depende ainda não chegaram — mesma altura de Input/Select (h-8) e mesmo espaçamento do FormItem (gap-y-2), para não haver salto de layout quando o campo real aparece.
import { FormField, FormFieldSkeleton } from '@kit/ui/form';
{isLoadingOptions ? (
<FormFieldSkeleton />
) : (
<FormField
name="estado"
render={({ field }) => (
<FormItem>
<FormLabel>Estado</FormLabel>
{/* ... */}
</FormItem>
)}
/>
)}
| Prop | Tipo | Padrão | Descrição |
|---|---|---|---|
className | string | — | Classe do wrapper externo (div). |
labelClassName | string | — | Classe da barra de skeleton que representa o label — use para ajustar a largura (w-24 por padrão). |
Use FormFieldSkeleton só para o estado de carregando de verdade (a busca ainda está em andamento). Para o estado de erro (a busca falhou), o campo deve continuar visível — ver TriggerStatus abaixo. Um skeleton em cima de um erro passaria a mensagem errada ("ainda carregando"), quando na real a busca já terminou e falhou.
TriggerStatus
Componente novo em @kit/ui/trigger-status. Mostra o estado de carregando ou erro dentro de um SelectTrigger/ComboboxTrigger, no lugar do SelectValue, de um jeito só — em vez das variações que existiam antes (spinner dentro do trigger, texto no placeholder, ou nada).
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@kit/ui/select';
import { TriggerStatus } from '@kit/ui/trigger-status';
<Select value={field.value} onValueChange={field.onChange} disabled={isOptionsError}>
<SelectTrigger>
{isOptionsError ? (
<TriggerStatus error />
) : (
<SelectValue placeholder="Selecione o estado" />
)}
</SelectTrigger>
<SelectContent>
{/* ... */}
</SelectContent>
</Select>
| Prop | Tipo | Padrão | Descrição |
|---|---|---|---|
loading | boolean | — | Mostra spinner + loadingLabel, em cinza (text-muted-foreground). |
error | boolean | — | Mostra ícone de alerta + errorLabel, em vermelho (text-destructive). Tem prioridade sobre loading se os dois vierem true. |
loadingLabel | string | "Carregando..." | Texto ao lado do spinner. |
errorLabel | string | "Erro ao carregar" | Texto ao lado do ícone de erro. |
className | string | — | Classe do span externo. |
Para um Combobox de busca (ComboboxInput, sem SelectTrigger), não há como encaixar o TriggerStatus como filho — nesse caso, sinalize o erro passando aria-invalid pro ComboboxInput (ativa a mesma borda/ring vermelhos que o TriggerStatus usa) e, se fizer sentido, troque o placeholder para o texto de erro. Ver grupo-empresa-combobox.tsx como referência.
Exemplo completo
Um campo assíncrono típico combina os três: FormFieldSkeleton durante o carregamento, TriggerStatus no erro, e Button com loading no submit. Trecho real de empresa-form-dialog.tsx:
{isLoadingOptions ? (
<FormFieldSkeleton />
) : (
<FormField
name="estado"
render={({ field }) => (
<FormItem>
<FormLabel>Estado</FormLabel>
<Select
value={field.value}
onValueChange={field.onChange}
disabled={isOptionsError}
>
<FormControl>
<SelectTrigger>
{isOptionsError ? (
<TriggerStatus error />
) : (
<SelectValue placeholder="Selecione o estado" />
)}
</SelectTrigger>
</FormControl>
<SelectContent>
{estadoOptions.map((uf) => (
<SelectItem key={uf} value={uf}>{uf}</SelectItem>
))}
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
)}
<Button type="submit" loading={isPending}>
Salvar alterações
</Button>
Quando não usar
Esses componentes representam estado assíncrono de verdade — uma busca que ainda não voltou. Se um "Consultar"/filtro só aplica um valor já carregado, em memória, de forma síncrona (useState puro, sem useQuery/useAction/navegação), não há nada para o loading representar — adicionar um spinner nesse caso seria cosmético e enganoso, sem refletir nenhuma espera real.
Localização dos arquivos
packages/ui/src/shadcn/button.tsx
packages/ui/src/shadcn/form.tsx
packages/ui/src/shadcn/trigger-status.tsx
Exportados em packages/ui/package.json:
"./button": "./src/shadcn/button.tsx",
"./form": "./src/shadcn/form.tsx",
"./trigger-status": "./src/shadcn/trigger-status.tsx"