Field
System układu formularzy — etykiety, kontrolki, opisy i błędy rozmieszczone spójnie, dostępnie i przy każdej szerokości.
Widoczna dla wszystkich, których zaprosisz.
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.jsonUż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
Na ten adres wyślemy link do logowania.
- 1EtykietaNazywa kontrolkę. Zawsze widoczna; powiązana przez htmlFor.
- 2OpisOpcjonalna wskazówka: format, limity, konsekwencje.
- 3KontrolkaDowolna kontrolka formularza prfct — tutaj Input.
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.
"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>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.
Podsumowanie aktywności w twoich projektach, co poniedziałek.
Powiadamiaj mnie, gdy ktoś oznaczy mnie w komentarzu.
Okazjonalne informacje o nowych funkcjach i usprawnieniach.
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.
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.
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.
Udostępniaj dokumentację pod własną domeną.
Gdzie twoje dane są przechowywane i przetwarzane.
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.
Nieprawidłowe dane
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.
Fieldrenderujerole="group";FieldSetiFieldLegendto natywne<fieldset>i<legend>, a technologie wspomagające ogłaszają legendę jako nazwę grupy. - Powiązania. Połącz każdy
FieldLabelz jego kontrolką przezhtmlFor/id, a opisy i błędy — przezaria-describedby. - Błędy są alertami.
FieldErrorrenderujerole="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-disableddoFieldidisableddo 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.
FieldSet i FieldLegend
FieldSet renderuje <fieldset>, a FieldLegend — jego <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.
FieldSeparator
Renderuje poziomą linię między sekcjami.