Przejdź do treści

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ólny FieldLegend; 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.
Ustawienia projektu
"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ć

  1. 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.
  2. 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.
  3. 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.
DobrzeKonkretna wskazówka tuż przy polu, z ikoną.
ŹleSamo czerwone obramowanie zawodzi osoby, które nie widzą koloru — i niczego nie wyjaśnia.

Lista kontrolna dostępności

  • Każda kontrolka ma etykietę dostępną programowo (htmlFor + id albo aria-label w kontrolkach z samą ikoną).
  • Tekst pomocniczy jest powiązany przez aria-describedby; błędy są odczytywane przez czytniki ekranu (FieldError używa role="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.