Formulários e validação
Estado de formulário, regras e validação por schema num pacote próprio: `@vitral/forms` não tem dependências nem framework, então os mesmos valores, erros e fluxo de envio funcionam no Vue, em outro framework ou em nenhum. Adaptadores para Zod, Yup, Valibot, Superstruct, ArkType e qualquer outra coisa que implemente Standard Schema.
@vitral/forms é a camada de formulários como um pacote próprio: valores, estado de tocado e de alterado, erros, envio, arrays de campos e caminhos aninhados, sem dependências e sem framework. O componente Form é um binding fino sobre ele, então tudo nesta página vale igual no Vue, em outro framework ou em nenhum.
Sem framework
O formulário é um objeto no qual você se inscreve. Ele cuida dos valores e dos erros; você cuida da marcação e decide o que fazer quando o estado muda. É isso que o torna reutilizável: um binding para React, uma store do Svelte ou uma página escrita à mão ficam todos sobre a mesma API.
import { createForm, rules } from '@vitral/forms';
const form = createForm({
initialValues: { email: '', password: '' },
rules: {
email: [rules.required(), rules.email()],
password: [rules.required(), rules.minLength(8)]
},
validateOn: 'blur',
onSubmit: (values) => api.signIn(values)
});
// The state is a plain object, and every change is published to whoever asks.
form.subscribe((state) => {
error.textContent = state.errors['email']?.[0] ?? '';
button.disabled = state.submitting;
});
input.addEventListener('input', () => form.setValue('email', input.value, { trigger: 'input' }));
input.addEventListener('blur', () => form.blur('email'));
element.addEventListener('submit', (event) => {
event.preventDefault();
form.submit();
});Regras
Regras são verificações por campo, declaradas num só lugar. Elas rodam no gatilho para o qual o formulário está configurado — submit por padrão, e depois a cada input quando o campo já foi validado uma vez, então um erro some assim que é corrigido. As mensagens vêm do locale, então um formulário em português fala português sem que ninguém precise pedir duas vezes.
import { createForm, rules } from '@vitral/forms';
createForm({
rules: {
name: [rules.required(), rules.maxLength(80)],
age: [rules.min(18, 'You have to be 18.')],
website: [rules.url()],
confirm: [rules.equalsField('password', 'The passwords differ.')],
// Anything else: return a message, or nothing when it is fine.
handle: [rules.custom(async (value) => ((await taken(value)) ? 'That handle is taken.' : undefined))]
},
// Cross-field checks, beside the rules and the resolver.
validate: (values) => (values.start > values.end ? { end: 'The end comes before the start.' } : null)
});Validadores de schema
Um resolver valida o formulário inteiro numa só chamada, que é para isso que serve uma biblioteca de schema. Cada adaptador é escrito sobre a menor forma que a sua biblioteca expõe — safeParse, validate, ~standard — então @vitral/forms não depende de nenhuma delas, e nenhuma entra no bundle a menos que você a importe. Regras, validate e um resolver podem ser usados ao mesmo tempo: os erros são mesclados por caminho.
import { z } from 'zod';
import { zodResolver } from '@vitral/forms';
const schema = z.object({
email: z.string().email(),
password: z.string().min(8),
handle: z.string().trim().toLowerCase()
});
const resolver = zodResolver(schema);
// Zod 3 and 4 both fit. Async refinements are awaited unless you pass
// `{ async: false }`, and what the schema transforms is what is submitted.import * as yup from 'yup';
import { yupResolver } from '@vitral/forms';
const schema = yup.object({
email: yup.string().required().email(),
password: yup.string().required().min(8)
});
// Every error, not just the first: the adapter passes `abortEarly: false`.
const resolver = yupResolver(schema);import * as v from 'valibot';
import { valibotResolver, standardSchemaResolver } from '@vitral/forms';
const schema = v.object({
email: v.pipe(v.string(), v.email()),
password: v.pipe(v.string(), v.minLength(8))
});
// Valibot 1 implements Standard Schema, so either of these works:
const resolver = valibotResolver(schema);
const same = standardSchemaResolver(schema);
// An older schema, or an async one, takes Valibot's own parser:
const asynchronous = valibotResolver(schema, { safeParse: v.safeParseAsync });import { object, string, size, pattern } from 'superstruct';
import { superstructResolver } from '@vitral/forms';
const struct = object({
email: pattern(string(), /^[^@\s]+@[^@\s]+$/),
password: size(string(), 8, 72)
});
// `coerce` applies the struct's coercions to what is submitted.
const resolver = superstructResolver(struct, { coerce: true });import { type } from 'arktype';
import { standardSchemaResolver } from '@vitral/forms';
const schema = type({ email: 'string.email', password: 'string >= 8' });
// ArkType, Valibot 1, Zod 3.24 and later, and anything else carrying
// `~standard`: one adapter covers the lot.
const resolver = standardSchemaResolver(schema);E quando a verificação cabe em poucas linhas, em vez de um schema:
import { functionResolver } from '@vitral/forms';
// Errors by path, or nested the way the values are. Anything falsy is "fine".
const resolver = functionResolver(async (values, { signal }) => ({
email: !values.email.includes('@') ? 'That is not an address.' : undefined,
confirm: values.password !== values.confirm ? 'The passwords differ.' : undefined,
address: { postcode: (await lookup(values.address.postcode, { signal })) ? undefined : 'No such postcode.' }
}));Do que cada adaptador precisa
| Adaptador | Biblioteca | O que ele chama | As transformações chegam aos valores enviados |
|---|---|---|---|
zodResolver | Zod 3 e 4 | safeParse, e safeParseAsync quando o schema tem refinamentos assíncronos | Sim |
yupResolver | Yup | validate, chamado com abortEarly: false | Sim, o valor após o cast |
valibotResolver | Valibot | ~standard, ou o safeParse que você entregar | Sim |
superstructResolver | Superstruct | validate, que retorna [error, value] | Com coerce |
standardSchemaResolver | ArkType, Effect Schema, qualquer coisa com ~standard | ~standard.validate | Sim |
functionResolver | a sua própria função | nada | Não |
Todo adaptador é assíncrono do ponto de vista do formulário, e uma validação substituída por outra mais nova é abortada pelo signal que recebe. O caminho de um problema é lido do jeito que cada biblioteca o informa — uma string com pontos, um array de chaves, uma lista de segmentos — e achatado para address.city, que é o caminho sob o qual um campo é registrado.
Erros vindos do servidor
A resposta de um servidor também é validação. normalizeErrors a recebe no formato em que chegar e a achata em caminhos; setErrors a coloca no formulário, e esses caminhos contam como validados, então as mensagens aparecem na hora.
import { normalizeErrors, mergeErrors } from '@vitral/forms';
// 422 from the server, in whatever shape it uses:
const fromServer = normalizeErrors({ address: { city: 'Unknown city.' }, email: ['Already registered.'] });
// → { 'address.city': ['Unknown city.'], email: ['Already registered.'] }
form.setErrors(fromServer);
const everything = mergeErrors(form.getState().errors, fromServer);Num formulário Vue
No Vue, o mesmo resolver vai no Form.Root, e os campos se ligam sozinhos: um controle dentro de um Form.Field recebe o seu valor, o seu nome, o seu estado de inválido e as relações entre o rótulo, a dica e a mensagem de erro.
<script setup lang="ts">
import { Form, InputText } from '@vitral/vue';
import { zodResolver } from '@vitral/forms';
import { z } from 'zod';
const schema = z.object({ email: z.string().email(), password: z.string().min(8) });
const values = ref({ email: '', password: '' });
</script>
<template>
<Form.Root v-model="values" :resolver="zodResolver(schema)" @submit="save">
<Form.Field name="email" label="Email">
<InputText type="email" />
</Form.Field>
<Form.Field name="password" label="Password">
<InputText type="password" />
</Form.Field>
<Form.Summary />
<Button type="submit" label="Create account" />
</Form.Root>
</template>A página do componente Form mostra isso funcionando, com arrays de campos, verificações assíncronas e um resumo que recebe o foco depois de um envio que falhou.