Formularze
Układ, walidacja, błędy i wysyłanie — zasady, dzięki którym formularz wypełnia się szybko, a pomylić się w nim trudno.
Formularz to miejsce, w którym produkt prosi ludzi o wysiłek. Każda z poniższych zasad istnieje po to, by ten wysiłek zmniejszyć: mniej decyzji, czytelniejsze błędy, żadnej utraconej pracy.
"use client"
import * as React from "react"
import { CircleAlertIcon } from "lucide-react"
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"
import { Button } from "@/components/ui/button"
import { Checkbox } from "@/components/ui/checkbox"
import {
Field,
FieldDescription,
FieldError,
FieldGroup,
FieldLabel,
} from "@/components/ui/field"
import { Input } from "@/components/ui/input"
import { toast } from "@/components/ui/toast"
type Errors = Partial<Record<"name" | "email" | "password" | "terms", string>>
function validate(data: FormData): Errors {
const errors: Errors = {}
const email = String(data.get("email") ?? "")
const password = String(data.get("password") ?? "")
if (!String(data.get("name") ?? "").trim()) errors.name = "Wpisz imię i nazwisko."
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email))
errors.email = "Wpisz adres e-mail, np. ada@acme.co."
if (password.length < 10) errors.password = "Użyj co najmniej 10 znaków."
if (!data.get("terms"))
errors.terms = "Zaakceptuj warunki, aby utworzyć konto."
return errors
}
export default function SignupForm() {
const [errors, setErrors] = React.useState<Errors>({})
const [submitted, setSubmitted] = React.useState(false)
const [pending, setPending] = React.useState(false)
const summary = React.useRef<HTMLDivElement>(null)
function onChange(event: React.FormEvent<HTMLFormElement>) {
// After a failed submit, an error clears the moment its field is fixed.
// New errors wait for the next submit: one appearing or vanishing on
// blur would move the button out from under a click.
if (!submitted) return
const next = validate(new FormData(event.currentTarget))
setErrors((current) => {
const kept: Errors = {}
for (const key of Object.keys(current) as (keyof Errors)[]) {
if (next[key]) kept[key] = next[key]
}
return kept
})
}
async function onSubmit(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault()
setSubmitted(true)
const next = validate(new FormData(event.currentTarget))
setErrors(next)
if (Object.keys(next).length > 0) {
requestAnimationFrame(() => summary.current?.focus())
return
}
setPending(true)
await new Promise((resolve) => setTimeout(resolve, 1200))
setPending(false)
toast.add({
title: "Utworzono konto",
description: "Sprawdź skrzynkę, aby potwierdzić adres e-mail.",
type: "success",
})
}
const count = Object.keys(errors).length
return (
<form
noValidate
onSubmit={onSubmit}
onChange={onChange}
className="w-full max-w-sm"
>
<FieldGroup>
{count > 0 && (
<div ref={summary} tabIndex={-1} className="outline-none">
<Alert variant="destructive">
<CircleAlertIcon />
<AlertTitle>
{count === 1
? "1 pole wymaga poprawy"
: `${count} pola wymagają poprawy`}
</AlertTitle>
<AlertDescription>
Popraw wyróżnione pola i spróbuj ponownie.
</AlertDescription>
</Alert>
</div>
)}
<Field data-invalid={errors.name ? true : undefined}>
<FieldLabel htmlFor="signup-name">Imię i nazwisko</FieldLabel>
<Input
id="signup-name"
name="name"
autoComplete="name"
aria-invalid={!!errors.name}
aria-describedby={errors.name ? "signup-name-error" : undefined}
/>
<FieldError id="signup-name-error">{errors.name}</FieldError>
</Field>
<Field data-invalid={errors.email ? true : undefined}>
<FieldLabel htmlFor="signup-email">Służbowy adres e-mail</FieldLabel>
<Input
id="signup-email"
name="email"
type="email"
autoComplete="email"
aria-invalid={!!errors.email}
aria-describedby={errors.email ? "signup-email-error" : undefined}
/>
<FieldError id="signup-email-error">{errors.email}</FieldError>
</Field>
<Field data-invalid={errors.password ? true : undefined}>
<FieldLabel htmlFor="signup-password">Hasło</FieldLabel>
<Input
id="signup-password"
name="password"
type="password"
autoComplete="new-password"
aria-invalid={!!errors.password}
aria-describedby="signup-password-help"
/>
{/* The error takes the hint's place and its id, so the input is
always described by whichever of the two is shown. */}
{errors.password ? (
<FieldError id="signup-password-help">{errors.password}</FieldError>
) : (
<FieldDescription id="signup-password-help">
Co najmniej 10 znaków.
</FieldDescription>
)}
</Field>
<Field
orientation="horizontal"
data-invalid={errors.terms ? true : undefined}
>
<Checkbox
id="signup-terms"
name="terms"
aria-invalid={!!errors.terms}
aria-describedby={errors.terms ? "signup-terms-error" : undefined}
/>
<FieldLabel htmlFor="signup-terms" className="font-normal">
Akceptuję warunki korzystania z usługi
</FieldLabel>
</Field>
{errors.terms && (
<FieldError id="signup-terms-error" className="-mt-3">
{errors.terms}
</FieldError>
)}
<Button type="submit" loading={pending} className="w-full">
Utwórz konto
</Button>
</FieldGroup>
</form>
)
}Układ
- Jedna kolumna. Formularze wielokolumnowe zaburzają kolejność czytania i ukrywają pola. Wyjątkiem są krótkie, powiązane pary — imię i nazwisko, data ważności i kod CVC.
- Etykiety nad polami. Etykiety umieszczone nad polem najszybciej się przegląda, a dłuższe tłumaczenia i powiększenie ich nie psują. Jako etykiety używaj
FieldLabel, nigdy placeholdera. - Grupuj za pomocą
FieldSet. Powiązane pola mają wspólnyFieldLegend; odstęp między grupami jest większy niż między polami wewnątrz grupy. - Dopasuj szerokość pola do odpowiedzi. Pole na kod pocztowy nie powinno być tak szerokie jak pole na adres.
- Poziome wiersze w ustawieniach. Na stronach ustawień etykieta i opis stoją po lewej, a kontrolka po prawej;
Field orientation="responsive"sprawia, że na wąskich ekranach elementy układają się jeden pod drugim.
"use client"
import * as React from "react"
import { Button } from "@/components/ui/button"
import {
Card,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from "@/components/ui/card"
import {
Field,
FieldContent,
FieldDescription,
FieldGroup,
FieldLabel,
FieldSeparator,
} from "@/components/ui/field"
import { Input } from "@/components/ui/input"
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select"
import { Switch } from "@/components/ui/switch"
import { toast } from "@/components/ui/toast"
const regions = [
{ label: "Frankfurt (eu-central-1)", value: "fra" },
{ label: "Warszawa (eu-central-2)", value: "waw" },
{ label: "Wirginia (us-east-1)", value: "iad" },
]
export default function SettingsForm() {
const [saving, setSaving] = React.useState(false)
async function save(event: React.FormEvent) {
event.preventDefault()
setSaving(true)
await new Promise((resolve) => setTimeout(resolve, 900))
setSaving(false)
toast.add({ title: "Zapisano ustawienia", type: "success" })
}
return (
<form onSubmit={save} className="w-full max-w-2xl">
<Card>
<CardHeader>
<CardTitle>Ustawienia projektu</CardTitle>
<CardDescription>Zmiany obejmą nowe wdrożenia.</CardDescription>
</CardHeader>
<CardContent>
<FieldGroup>
<Field orientation="responsive">
<FieldContent>
<FieldLabel htmlFor="project-name">Nazwa projektu</FieldLabel>
<FieldDescription>
Używana w adresach URL i powiadomieniach.
</FieldDescription>
</FieldContent>
<Input
id="project-name"
defaultValue="acme-web"
className="sm:max-w-64"
/>
</Field>
<Field orientation="responsive">
<FieldContent>
<FieldLabel htmlFor="project-region">Region</FieldLabel>
<FieldDescription>
Gdzie domyślnie działają funkcje.
</FieldDescription>
</FieldContent>
<Select items={regions} defaultValue="fra">
<SelectTrigger
id="project-region"
className="w-full sm:max-w-64"
>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectGroup>
{regions.map((region) => (
<SelectItem key={region.value} value={region.value}>
{region.label}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
</Field>
<FieldSeparator />
<Field orientation="horizontal">
<FieldContent>
<FieldLabel htmlFor="preview-deploys">
Wdrożenia podglądowe
</FieldLabel>
<FieldDescription>
Wdrażaj każdy pull request pod unikalnym adresem URL.
</FieldDescription>
</FieldContent>
<Switch id="preview-deploys" defaultChecked />
</Field>
<Field orientation="horizontal">
<FieldContent>
<FieldLabel htmlFor="protect">Ochrona hasłem</FieldLabel>
<FieldDescription>
Wymagaj hasła do wyświetlania podglądów.
</FieldDescription>
</FieldContent>
<Switch id="protect" />
</Field>
</FieldGroup>
</CardContent>
<CardFooter className="justify-end">
<Button type="submit" loading={saving}>
Zapisz zmiany
</Button>
</CardFooter>
</Card>
</form>
)
}Pola wymagane i opcjonalne
Oznaczaj mniejszość. Większość formularzy powinna prosić tylko o to, co niezbędne — wtedy nieliczne pola opcjonalne opisz dopiskiem „(opcjonalnie)”, a wymaganych nie oznaczaj wcale. Gdy większość pól jest opcjonalna, oznacz za to pola wymagane. Nigdy nie polegaj na samej gwiazdce: wielu osobom i czytnikom ekranu nic ona nie mówi.
Walidacja
Kiedy walidować
- Przy wysyłaniu sprawdź wszystko i przenieś fokus do pierwszego błędnego pola, a w formularzach dłuższych niż jeden ekran do podsumowania błędów na górze.
- Po pierwszym wysłaniu sprawdzaj pola w trakcie pisania i usuwaj błąd, gdy tylko pole zostanie poprawione. Nowe błędy czekają na kolejne wysłanie. Nie sprawdzaj przy
blur: komunikat, który pojawia się lub znika, gdy fokus opuszcza pole, przesuwa przycisk wysyłania spod kliknięcia. - Nigdy przy każdym naciśnięciu klawisza przed pierwszym wysłaniem — komunikat, że adres e-mail jest nieprawidłowy, już po wpisaniu jednej litery jest wręcz wrogi.
Jak pokazywać błędy
- Umieść komunikat bezpośrednio pod polem za pomocą
FieldError, który wyświetla ikonę i tekst — nigdy sam kolor. - Oznacz kontrolkę atrybutem
aria-invalid, a pole —data-invalid. - Napisz, co jest nie tak i jak to naprawić: Wpisz adres e-mail, np. ada@acme.co, a nie Nieprawidłowe dane.
- W formularzach dłuższych niż jeden ekran dodaj podsumowanie w
Alert, które wymienia problemy i otrzymuje fokus.
<Field data-invalid>
<FieldLabel htmlFor="email">Work email</FieldLabel>
<Input id="email" aria-invalid />
<FieldError>Enter an email address like ada@acme.co.</FieldError>
</Field>Wysyłanie
- Nie wyłączaj przycisku wysyłania. Wyłączony przycisk nie wyjaśni, czego brakuje; próba wysłania i pokazanie błędów owszem. Jedyny wyjątek to potwierdzenie wpisaniem nazwy (zobacz Akcje destrukcyjne): tam wpisana nazwa jest odpowiedzią, a przycisk na nią czeka.
- Pokazuj postęp na przycisku za pomocą
loading. Przycisk zachowuje szerokość i fokus, a przy tym blokuje podwójne wysłanie. - Potwierdzaj powodzenie powiadomieniem, jeśli ktoś zostaje na stronie, albo od razu przejdź do wyniku.
- Nigdy nie czyść formularza po błędzie. Błędy serwera pokazuj przy polach albo w podsumowaniu, a wszystkie wpisane wartości zachowaj.
Dopisz końcówkę domeny, na przykład .com.
Lista kontrolna dostępności
- Każda kontrolka ma etykietę dostępną programowo (
htmlFor+idalboaria-labelw kontrolkach z samą ikoną). - Tekst pomocniczy jest powiązany przez
aria-describedby; błędy są odczytywane przez czytniki ekranu (FieldErrorużywarole="alert"). - Pola z danymi osobowymi mają ustawione atrybuty
autocomplete(name,email,new-password), żeby przeglądarki i menedżery haseł mogły pomóc w ich wypełnieniu. - Formularz da się w pełni obsłużyć klawiaturą — w logicznej kolejności i z widocznym fokusem.