Przejdź do treści

Field

System układu formularzy — etykiety, kontrolki, opisy i błędy rozmieszczone spójnie, dostępnie i przy każdej szerokości.

Kod źródłowyRejestr
import {
  Field,
  FieldContent,
  FieldDescription,
  FieldGroup,
  FieldLabel,
} from "@/components/ui/field"
import { Input } from "@/components/ui/input"
import { Switch } from "@/components/ui/switch"

export default function FieldDemo() {
  return (
    <FieldGroup className="w-72">
      <Field>
        <FieldLabel htmlFor="field-demo-name">Nazwa przestrzeni roboczej</FieldLabel>
        <Input id="field-demo-name" defaultValue="Acme Research" />
        <FieldDescription>Widoczna dla wszystkich, których zaprosisz.</FieldDescription>
      </Field>
      <Field orientation="horizontal">
        <FieldContent>
          <FieldLabel htmlFor="field-demo-sso">Wymagaj SSO</FieldLabel>
        </FieldContent>
        <Switch id="field-demo-sso" defaultChecked />
      </Field>
    </FieldGroup>
  )
}

Instalacja

$ pnpm dlx shadcn@latest add https://www.prfct.dev/r/field.json

Użycie

import {
  Field,
  FieldDescription,
  FieldError,
  FieldGroup,
  FieldLabel,
  FieldLegend,
  FieldSet,
} from "@/components/ui/field"
<FieldSet>
  <FieldLegend>Profile</FieldLegend>
  <FieldGroup>
    <Field>
      <FieldLabel htmlFor="name">Name</FieldLabel>
      <Input id="name" />
      <FieldDescription>Shown on your public profile.</FieldDescription>
    </Field>
  </FieldGroup>
</FieldSet>

Field współpracuje z każdą kontrolką prfct — Input, Textarea, Select, Checkbox, Radio Group, Switch, Slider i innymi — dzięki temu wszystkie formularze w produkcie mają ten sam rytm.

Anatomia

  1. 1EtykietaNazywa kontrolkę. Zawsze widoczna; powiązana przez htmlFor.
  2. 2OpisOpcjonalna wskazówka: format, limity, konsekwencje.
  3. 3KontrolkaDowolna kontrolka formularza prfct — tutaj Input.
CzęśćRola
FieldSet / FieldLegendNatywny <fieldset> i jego podpis. Grupuje powiązane kontrolki i nadaje grupie nazwę dla technologii wspomagających.
FieldGroupPionowy rytm między polami — domyślnie 20 px, w grupach pól wyboru 12 px. Wyznacza też kontener dla zapytań kontenerowych, na które reagują responsywne pola.
FieldJedna kontrolka z etykietą, opisem i błędem. role="group", układ pionowy, poziomy lub responsywny.
FieldContentW polach poziomych układa etykietę i opis jedno pod drugim obok kontrolki.
FieldLabel / FieldTitleEtykieta kontrolki; FieldTitle to nagłówek kart wyboru, który nie jest etykietą.
FieldDescriptionTekst pomocniczy pod kontrolką.
FieldErrorKomunikat walidacji z ikoną, ogłaszany jako alert.
FieldSeparatorSeparator między sekcjami z opcjonalną etykietą pośrodku.

Przykłady

Formularz

Kompletny formularz: FieldSet z legendą i opisem, dwukolumnowy wiersz na imię i nazwisko, lista wyboru, wielowierszowe pole tekstowe, separator i akcje. Wszystko dziedziczy te same odstępy bez ani jednej klasy marginesu.

Profil
"use client"

import { Button } from "@/components/ui/button"
import { Checkbox } from "@/components/ui/checkbox"
import {
  Field,
  FieldDescription,
  FieldGroup,
  FieldLabel,
  FieldLegend,
  FieldSeparator,
  FieldSet,
} from "@/components/ui/field"
import { Input } from "@/components/ui/input"
import {
  Select,
  SelectContent,
  SelectGroup,
  SelectItem,
  SelectTrigger,
  SelectValue,
} from "@/components/ui/select"
import { Textarea } from "@/components/ui/textarea"

const roles = [
  { label: "Projektowanie", value: "design" },
  { label: "Inżynieria", value: "engineering" },
  { label: "Produkt", value: "product" },
  { label: "Badania", value: "research" },
]

export default function FieldForm() {
  return (
    <form
      className="w-full max-w-lg"
      onSubmit={(event) => event.preventDefault()}
    >
      <FieldGroup>
        <FieldSet>
          <FieldLegend>Profil</FieldLegend>
          <FieldDescription>
            Tak widzą cię członkowie zespołu w całej przestrzeni roboczej.
          </FieldDescription>
          <FieldGroup>
            <div className="grid gap-5 sm:grid-cols-2">
              <Field>
                <FieldLabel htmlFor="field-form-first">Imię</FieldLabel>
                <Input
                  id="field-form-first"
                  autoComplete="given-name"
                  defaultValue="Ada"
                />
              </Field>
              <Field>
                <FieldLabel htmlFor="field-form-last">Nazwisko</FieldLabel>
                <Input
                  id="field-form-last"
                  autoComplete="family-name"
                  defaultValue="Lovelace"
                />
              </Field>
            </div>
            <Field>
              <FieldLabel htmlFor="field-form-team">Zespół</FieldLabel>
              <Select items={roles} defaultValue="engineering">
                <SelectTrigger id="field-form-team" className="w-full">
                  <SelectValue />
                </SelectTrigger>
                <SelectContent>
                  <SelectGroup>
                    {roles.map((role) => (
                      <SelectItem key={role.value} value={role.value}>
                        {role.label}
                      </SelectItem>
                    ))}
                  </SelectGroup>
                </SelectContent>
              </Select>
            </Field>
            <Field>
              <FieldLabel htmlFor="field-form-about">O mnie</FieldLabel>
              <Textarea
                id="field-form-about"
                placeholder="Nad czym pracujesz?"
              />
              <FieldDescription>Możesz używać Markdownu.</FieldDescription>
            </Field>
          </FieldGroup>
        </FieldSet>
        <FieldSeparator />
        <Field orientation="horizontal">
          <Checkbox id="field-form-public" defaultChecked />
          <FieldLabel htmlFor="field-form-public" className="font-normal">
            Pokazuj mój profil w katalogu członków
          </FieldLabel>
        </Field>
        <Field orientation="horizontal" className="justify-end">
          <Button type="button" variant="outline">
            Anuluj
          </Button>
          <Button type="submit">Zapisz profil</Button>
        </Field>
      </FieldGroup>
    </form>
  )
}

Walidacja

Oznacz Field atrybutem data-invalid, a kontrolkę atrybutem aria-invalid, i wyrenderuj FieldError powiązany przez aria-describedby. Przekaż listę do errors, żeby pokazać kilka reguł naraz. Wyślij ten formularz, żeby zobaczyć, jak to działa. Przykład sprawdza pola ponownie dopiero przy kolejnym wysłaniu.

"use client"

import * as React from "react"

import { Button } from "@/components/ui/button"
import {
  Field,
  FieldDescription,
  FieldError,
  FieldGroup,
  FieldLabel,
} from "@/components/ui/field"
import { Input } from "@/components/ui/input"

type Errors = { email?: string; password?: { message: string }[] }

function validate(data: FormData): Errors {
  const email = String(data.get("email") ?? "")
  const password = String(data.get("password") ?? "")
  const errors: Errors = {}

  if (!/^\S+@\S+\.\S+$/.test(email)) {
    errors.email = "Wpisz adres e-mail, np. name@company.com."
  }
  const rules = [
    password.length < 12 && { message: "Użyj co najmniej 12 znaków." },
    !/\d/.test(password) && { message: "Dodaj co najmniej jedną cyfrę." },
  ].filter(Boolean) as { message: string }[]
  if (rules.length) errors.password = rules

  return errors
}

export default function FieldValidation() {
  const [errors, setErrors] = React.useState<Errors>({})

  return (
    <form
      noValidate
      className="w-full max-w-sm"
      onSubmit={(event) => {
        event.preventDefault()
        setErrors(validate(new FormData(event.currentTarget)))
      }}
    >
      <FieldGroup>
        <Field data-invalid={errors.email ? true : undefined}>
          <FieldLabel htmlFor="field-validation-email">E-mail</FieldLabel>
          <Input
            id="field-validation-email"
            name="email"
            type="email"
            defaultValue="ada@"
            aria-invalid={errors.email ? true : undefined}
            aria-describedby={
              errors.email ? "field-validation-email-error" : undefined
            }
          />
          {errors.email && (
            <FieldError id="field-validation-email-error">
              {errors.email}
            </FieldError>
          )}
        </Field>
        <Field data-invalid={errors.password ? true : undefined}>
          <FieldLabel htmlFor="field-validation-password">Hasło</FieldLabel>
          <Input
            id="field-validation-password"
            name="password"
            type="password"
            defaultValue="hunter"
            aria-invalid={errors.password ? true : undefined}
            aria-describedby="field-validation-password-help"
          />
          {errors.password ? (
            <FieldError
              id="field-validation-password-help"
              errors={errors.password}
            />
          ) : (
            <FieldDescription id="field-validation-password-help">
              Co najmniej 12 znaków, w tym cyfra.
            </FieldDescription>
          )}
        </Field>
        <Button type="submit" className="w-full">
          Utwórz konto
        </Button>
      </FieldGroup>
    </form>
  )
}
<Field data-invalid>
  <FieldLabel htmlFor="email">Email</FieldLabel>
  <Input id="email" aria-invalid aria-describedby="email-error" />
  <FieldError id="email-error">Enter an email address like name@company.com.</FieldError>
</Field>
Dlaczego oba atrybuty?
aria-invalid na kontrolce to atrybut, który odczytują technologie wspomagające i który barwi obramowanie na czerwono. data-invalid na Field to punkt zaczepienia dla stylów i testów całego pola — dzięki niemu dopasujesz otaczające elementy bez sięgania do wnętrza kontrolki.

Układ poziomy

Strony ustawień najlepiej czyta się jako wiersze: etykieta i opis po lewej, kontrolka po prawej. Użyj orientation="horizontal" razem z opakowaniem FieldContent.

import {
  Field,
  FieldContent,
  FieldDescription,
  FieldGroup,
  FieldLabel,
  FieldSeparator,
} from "@/components/ui/field"
import { Switch } from "@/components/ui/switch"

const settings = [
  {
    id: "digest",
    label: "Cotygodniowe podsumowanie",
    description: "Podsumowanie aktywności w twoich projektach, co poniedziałek.",
    checked: true,
  },
  {
    id: "mentions",
    label: "Wzmianki",
    description: "Powiadamiaj mnie, gdy ktoś oznaczy mnie w komentarzu.",
    checked: true,
  },
  {
    id: "marketing",
    label: "Nowości w produkcie",
    description: "Okazjonalne informacje o nowych funkcjach i usprawnieniach.",
    checked: false,
  },
]

export default function FieldHorizontal() {
  return (
    <FieldGroup className="w-full max-w-md gap-4">
      {settings.map((setting, index) => (
        <div key={setting.id} className="contents">
          {index > 0 && <FieldSeparator />}
          <Field orientation="horizontal">
            <FieldContent>
              <FieldLabel htmlFor={`field-horizontal-${setting.id}`}>
                {setting.label}
              </FieldLabel>
              <FieldDescription>{setting.description}</FieldDescription>
            </FieldContent>
            <Switch
              id={`field-horizontal-${setting.id}`}
              defaultChecked={setting.checked}
            />
          </Field>
        </div>
      ))}
    </FieldGroup>
  )
}

Karty wyboru

Owiń cały Field w FieldLabel, a stanie się kartą, którą można zaznaczyć. Celem kliknięcia jest cała karta, a zaznaczenie zabarwia ją kolorem marki. Działa tak samo z grupami przycisków radiowych, jak i z polami wyboru.

Plan
import {
  Field,
  FieldContent,
  FieldDescription,
  FieldLabel,
  FieldLegend,
  FieldSet,
  FieldTitle,
} from "@/components/ui/field"
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"

const plans = [
  {
    value: "starter",
    title: "Starter",
    description: "Dla osób indywidualnych. 3 projekty, wsparcie społeczności.",
    price: "0 $",
  },
  {
    value: "team",
    title: "Team",
    description: "Dla małych zespołów. Bez limitu projektów i ról.",
    price: "12 $",
  },
  {
    value: "enterprise",
    title: "Enterprise",
    description: "SSO, logi audytowe i dedykowany opiekun.",
    price: "Indywidualnie",
  },
]

export default function FieldChoiceCards() {
  return (
    <FieldSet className="w-full max-w-md">
      <FieldLegend variant="label">Plan</FieldLegend>
      <RadioGroup defaultValue="team">
        {plans.map((plan) => (
          <FieldLabel key={plan.value} htmlFor={`field-plan-${plan.value}`}>
            <Field orientation="horizontal">
              <RadioGroupItem
                value={plan.value}
                id={`field-plan-${plan.value}`}
              />
              <FieldContent>
                <FieldTitle>{plan.title}</FieldTitle>
                <FieldDescription>{plan.description}</FieldDescription>
              </FieldContent>
              <span className="text-sm font-medium text-foreground tabular-nums">
                {plan.price}
              </span>
            </Field>
          </FieldLabel>
        ))}
      </RadioGroup>
    </FieldSet>
  )
}

Grupa pól wyboru

Zgrupuj powiązane pola wyboru w FieldSet z FieldLegend — czytniki ekranu ogłaszają legendę, gdy fokus wchodzi do grupy. Nadaj wewnętrznemu FieldGroup atrybut data-slot="checkbox-group", żeby zagęścić odstępy.

Alerty o incydentach
import { Checkbox } from "@/components/ui/checkbox"
import {
  Field,
  FieldDescription,
  FieldGroup,
  FieldLabel,
  FieldLegend,
  FieldSet,
} from "@/components/ui/field"

const channels = [
  { id: "email", label: "E-mail", checked: true },
  { id: "push", label: "Powiadomienia push", checked: true },
  { id: "sms", label: "SMS", checked: false },
  { id: "slack", label: "Slack", checked: false },
]

export default function FieldCheckboxGroup() {
  return (
    <FieldSet className="w-full max-w-xs">
      <FieldLegend variant="label">Alerty o incydentach</FieldLegend>
      <FieldDescription>
        Gdzie mamy cię powiadomić, gdy monitor wykryje awarię?
      </FieldDescription>
      <FieldGroup data-slot="checkbox-group">
        {channels.map((channel) => (
          <Field key={channel.id} orientation="horizontal">
            <Checkbox
              id={`field-channel-${channel.id}`}
              name="channels"
              value={channel.id}
              defaultChecked={channel.checked}
            />
            <FieldLabel
              htmlFor={`field-channel-${channel.id}`}
              className="font-normal"
            >
              {channel.label}
            </FieldLabel>
          </Field>
        ))}
      </FieldGroup>
    </FieldSet>
  )
}

Układ responsywny

orientation="responsive" w wąskich kontenerach układa etykietę i kontrolkę jedna pod drugą, a gdy otaczający FieldGroup jest szerszy niż kontenerowy breakpoint md — obok siebie. Reaguje na kontener, a nie na okno przeglądarki, więc ten sam formularz działa w oknie dialogowym i na pełnej stronie.

import {
  Field,
  FieldContent,
  FieldDescription,
  FieldGroup,
  FieldLabel,
  FieldSeparator,
} from "@/components/ui/field"
import { Input } from "@/components/ui/input"
import { NativeSelect, NativeSelectOption } from "@/components/ui/native-select"

export default function FieldResponsive() {
  return (
    <FieldGroup className="w-full max-w-2xl">
      <Field orientation="responsive">
        <FieldContent>
          <FieldLabel htmlFor="field-responsive-domain">
            Własna domena
          </FieldLabel>
          <FieldDescription>
            Udostępniaj dokumentację pod własną domeną.
          </FieldDescription>
        </FieldContent>
        <Input
          id="field-responsive-domain"
          placeholder="docs.acme.com"
          className="@md/field-group:w-64"
        />
      </Field>
      <FieldSeparator />
      <Field orientation="responsive">
        <FieldContent>
          <FieldLabel htmlFor="field-responsive-region">Region</FieldLabel>
          <FieldDescription>
            Gdzie twoje dane są przechowywane i przetwarzane.
          </FieldDescription>
        </FieldContent>
        <NativeSelect
          id="field-responsive-region"
          defaultValue="eu-central"
          className="@md/field-group:w-64"
        >
          <NativeSelectOption value="us-east">
            Wschodnie USA (Wirginia)
          </NativeSelectOption>
          <NativeSelectOption value="eu-central">
            Europa Środkowa (Frankfurt)
          </NativeSelectOption>
          <NativeSelectOption value="ap-south">
            Azja i Pacyfik (Mumbaj)
          </NativeSelectOption>
        </NativeSelect>
      </Field>
    </FieldGroup>
  )
}

Wytyczne

Kiedy używać

  • W każdym formularzu — od pojedynczego pola po wieloetapowy proces.
  • Na stronach ustawień, z polami w układzie poziomym.
  • W kartach do zaznaczenia, które działają jak przyciski radiowe lub pola wyboru.

Kiedy nie używać

  • Przy edycji w miejscu w tabelach lub paskach narzędzi, gdzie etykieta byłaby wizualnym szumem — nazwij wtedy kontrolkę przez aria-label.
  • Przy filtrach i polach wyszukiwania poza formularzem — zwykle wystarczy sam Input Group.

Jedna kolumna

Układaj formularze w jednej kolumnie. Formularze wielokolumnowe przerywają pionową ścieżkę, którą podąża wzrok, i łatwo wypełnić je w złej kolejności. Stawiaj pola obok siebie tylko wtedy, gdy stanowią całość, np. imię i nazwisko albo miejscowość i kod pocztowy.

Najpierw sprawdź, potem wyjaśnij

Waliduj przy wysłaniu formularza, nigdy przy pierwszym naciśnięciu klawisza; potem usuwaj każdy błąd, gdy tylko pole zostanie poprawione (zobacz Formularze). Umieść błąd tuż pod polem, którego dotyczy, napisz, co poszło nie tak i jak to naprawić, i zachowaj to, co ktoś wpisał — nie czyść pola. W długich formularzach przenieś też fokus na pierwsze błędne pole.

DobrzeKonkretny komunikat: mówi, co jest nie tak i jak to naprawić, a oprócz koloru pokazuje ikonę.

Nieprawidłowe dane

ŹleOgólnikowy komunikat, a jedynym sygnałem jest kolor — dla wielu osób niewidoczny.

Opis musi zasłużyć na swoje miejsce

Dodawaj FieldDescription tylko wtedy, gdy opis wpływa na to, co ludzie zrobią: podaje format, limit albo konsekwencję. Opis, który powtarza etykietę, to szum. Jeśli każde pole potrzebuje opisu, prawdopodobnie trzeba poprawić etykiety.

Dostępność

  • Grupowanie. Field renderuje role="group"; FieldSet i FieldLegend to natywne <fieldset> i <legend>, a technologie wspomagające ogłaszają legendę jako nazwę grupy.
  • Powiązania. Połącz każdy FieldLabel z jego kontrolką przez htmlFor/id, a opisy i błędy — przez aria-describedby.
  • Błędy są alertami. FieldError renderuje role="alert", więc jest ogłaszany w chwili pojawienia się. Gdy kilka pól ma błędy jednocześnie, rozważ zamiast tego ogłoszenie jednego podsumowania.
  • Nie tylko kolorem. Błędne pola łączą czerwone obramowanie, ikonę i komunikat tekstowy (WCAG 1.4.1).
  • Duże cele. W kartach wyboru klikalna jest cała karta — znacznie więcej niż minimalne 24 × 24 px (WCAG 2.5.8).
  • Wyłączone pola. Dodaj data-disabled do Field i disabled do kontrolki; etykieta przygasa razem z nią. Opis zachowuje swój kolor, więc powód niedostępności pola pozostaje czytelny.

Dokumentacja API

Field

Renderuje <div role="group">. Przyjmuje wszystkie propsy div.

PropTypDomyślnie
orientation

Układa części jedna pod drugą, w jednym wierszu albo zmienia układ, gdy FieldGroup osiągnie kontenerową szerokość md.

"vertical" | "horizontal" | "responsive""vertical"
data-invalid

Oznacza pole jako błędne na potrzeby stylów i testów. Łącz z aria-invalid na kontrolce.

booleanBrak wartości domyślnej
data-disabled

Przygasza FieldLabel i FieldTitle. Opis nie jest przygaszany. Łącz z disabled na kontrolce.

booleanBrak wartości domyślnej

FieldSet i FieldLegend

FieldSet renderuje <fieldset>, a FieldLegend — jego <legend>.

PropTypDomyślnie
variant

Tylko dla FieldLegend. Wartość legend daje tytuł sekcji w rozmiarze 16 px, a label wygląda jak etykieta pola — do małych grup.

"legend" | "label""legend"

FieldGroup

Renderuje <div>, który układa pola w stos z odstępami 20 px i tworzy kontener field-group dla responsywnych pól. Ustaw data-slot="checkbox-group", żeby uzyskać odstępy 12 px.

FieldLabel, FieldTitle i FieldContent

FieldLabel renderuje Label i przyjmuje jego propsy. Gdy owija Field, staje się kartą wyboru. FieldTitle renderuje <div> wystylizowany jak etykieta — na nagłówki kart, które nie są dostępną nazwą kontrolki. FieldContent renderuje <div>, który układa etykietę i opis jedno pod drugim.

FieldDescription

Renderuje <p> z tekstem pomocniczym w rozmiarze 13 px i kolorze muted-foreground. Linki w środku mają kolor link.

FieldError

Renderuje <div role="alert"> z ikoną ostrzeżenia. Gdy nie ma treści, nie renderuje niczego.

PropTypDomyślnie
children

Komunikat. Ma pierwszeństwo przed errors.

ReactNodeBrak wartości domyślnej
errors

Komunikaty z biblioteki do walidacji. Duplikaty są usuwane; kilka komunikatów wyświetla się jako lista.

Array<{ message?: string } | undefined>Brak wartości domyślnej

FieldSeparator

Renderuje poziomą linię między sekcjami.

PropTypDomyślnie
children

Opcjonalna etykieta pośrodku linii, np. „lub”.

ReactNodeBrak wartości domyślnej