Vitral 0.2
Referência

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.

Exemplo
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.

Exemplo
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.

Exemplo
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.
Exemplo
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);
Exemplo
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 });
Exemplo
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 });
Exemplo
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:

Exemplo
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

AdaptadorBibliotecaO que ele chamaAs transformações chegam aos valores enviados
zodResolverZod 3 e 4safeParse, e safeParseAsync quando o schema tem refinamentos assíncronosSim
yupResolverYupvalidate, chamado com abortEarly: falseSim, o valor após o cast
valibotResolverValibot~standard, ou o safeParse que você entregarSim
superstructResolverSuperstructvalidate, que retorna [error, value]Com coerce
standardSchemaResolverArkType, Effect Schema, qualquer coisa com ~standard~standard.validateSim
functionResolvera sua própria funçãonadaNã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.

Exemplo
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.

Exemplo
<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.