Bỏ qua đến nội dung

Trường

Các trường dành cho nhãn, trường điều khiển, văn bản hướng dẫn và thông báo lỗi trong các bảng cài đặt, biểu mẫu thanh toán và quy trình đăng ký — với phong cách thiết kế biểu mẫu theo trường phái tân thô mộc.

import { Button } from "@/components/ui/button"
import { Checkbox } from "@/components/ui/checkbox"
import {

Field gói nhãn, điều khiển, dòng chú thích và thông báo lỗi vào một khối được canh chỉnh gọn gàng, rồi mở rộng lên thành các phần được nhóm lại với FieldGroupFieldSet. Bên dưới không có primitive nào — chỉ là ngữ nghĩa fieldset, legendrole="group" thuần, kết hợp với LabelSeparator, tạo kiểu theo công thức neobrutalist: kiểu chữ tiêu đề đậm cho legend và nhãn, còn viền dày cùng bóng đổ đậm do chính các điều khiển bên trong mang lại.

Nên dùng khi:

  • Trang cài đặt — xếp các phần hồ sơ, thông báo và thanh toán bằng legend của FieldSet, chen FieldSeparator giữa các nhóm.
  • Biểu mẫu thanh toán và đăng ký — giữ nhãn, mô tả và lỗi canh đều nhau trên các input, select và switch.
  • Thẻ lựa chọn — bọc radio, checkbox hoặc switch trong các thẻ FieldLabel chọn được, dùng cho bảng chọn gói và bảng tùy chọn.

Cài đặt

pnpm dlx shadcn@latest add https://neobrutalism.com/r/base/field.json
npx shadcn@latest add https://neobrutalism.com/r/base/field.json
yarn dlx shadcn@latest add https://neobrutalism.com/r/base/field.json
bunx --bun shadcn@latest add https://neobrutalism.com/r/base/field.json

Sao chép và dán đoạn mã sau vào dự án của bạn.

components/ui/field.tsx
"use client"

import { useMemo } from "react"
import { cva, type VariantProps } from "class-variance-authority"

import { cn } from "@/lib/utils"
import { Label } from "@/components/ui/label"
import { Separator } from "@/components/ui/separator"

function FieldSet({ className, ...props }: React.ComponentProps<"fieldset">) {
  return (
    <fieldset
      data-slot="field-set"
      className={cn(
        "flex flex-col gap-4 has-[>[data-slot=checkbox-group]]:gap-3 has-[>[data-slot=radio-group]]:gap-3",
        className
      )}
      {...props}
    />
  )
}

function FieldLegend({
  className,
  variant = "legend",
  ...props
}: React.ComponentProps<"legend"> & { variant?: "legend" | "label" }) {
  return (
    <legend
      data-slot="field-legend"
      data-variant={variant}
      className={cn(
        "mb-1.5 font-head font-medium data-[variant=label]:text-sm data-[variant=legend]:text-base",
        className
      )}
      {...props}
    />
  )
}

function FieldGroup({ className, ...props }: React.ComponentProps<"div">) {
  return (
    <div
      data-slot="field-group"
      className={cn(
        "group/field-group @container/field-group flex w-full flex-col gap-5 data-[slot=checkbox-group]:gap-3 *:data-[slot=field-group]:gap-4",
        className
      )}
      {...props}
    />
  )
}

const fieldVariants = cva(
  "group/field flex w-full gap-2 data-[invalid=true]:text-destructive",
  {
    variants: {
      orientation: {
        vertical: "flex-col *:w-full [&>.sr-only]:w-auto",
        horizontal:
          "flex-row items-center has-[>[data-slot=field-content]]:items-start *:data-[slot=field-label]:flex-auto has-[>[data-slot=field-content]]:[&>[role=checkbox],[role=radio]]:mt-px",
        responsive:
          "flex-col *:w-full @md/field-group:flex-row @md/field-group:items-center @md/field-group:*:w-auto @md/field-group:has-[>[data-slot=field-content]]:items-start @md/field-group:*:data-[slot=field-label]:flex-auto [&>.sr-only]:w-auto @md/field-group:has-[>[data-slot=field-content]]:[&>[role=checkbox],[role=radio]]:mt-px",
      },
    },
    defaultVariants: {
      orientation: "vertical",
    },
  }
)

function Field({
  className,
  orientation = "vertical",
  ...props
}: React.ComponentProps<"div"> & VariantProps<typeof fieldVariants>) {
  return (
    <div
      role="group"
      data-slot="field"
      data-orientation={orientation}
      className={cn(fieldVariants({ orientation }), className)}
      {...props}
    />
  )
}

function FieldContent({ className, ...props }: React.ComponentProps<"div">) {
  return (
    <div
      data-slot="field-content"
      className={cn(
        "group/field-content flex flex-1 flex-col gap-0.5 leading-snug",
        className
      )}
      {...props}
    />
  )
}

function FieldLabel({
  className,
  ...props
}: React.ComponentProps<typeof Label>) {
  return (
    <Label
      data-slot="field-label"
      className={cn(
        "group/field-label peer/field-label flex w-fit gap-2 leading-snug group-data-[disabled=true]/field:opacity-50 has-data-checked:border-primary/30 has-data-checked:bg-primary/5 has-[>[data-slot=field]]:rounded has-[>[data-slot=field]]:border-2 *:data-[slot=field]:p-2.5 dark:has-data-checked:border-primary/20 dark:has-data-checked:bg-primary/10",
        "has-[>[data-slot=field]]:w-full has-[>[data-slot=field]]:flex-col",
        className
      )}
      {...props}
    />
  )
}

function FieldTitle({ className, ...props }: React.ComponentProps<"div">) {
  return (
    <div
      data-slot="field-label"
      className={cn(
        "flex w-fit items-center gap-2 text-sm font-head font-medium group-data-[disabled=true]/field:opacity-50",
        className
      )}
      {...props}
    />
  )
}

function FieldDescription({ className, ...props }: React.ComponentProps<"p">) {
  return (
    <p
      data-slot="field-description"
      className={cn(
        "text-left text-sm leading-normal font-normal text-muted-foreground group-has-data-horizontal/field:text-balance [[data-variant=legend]+&]:-mt-1.5",
        "last:mt-0 nth-last-2:-mt-1",
        "[&>a]:underline [&>a]:underline-offset-4 [&>a:hover]:text-primary",
        className
      )}
      {...props}
    />
  )
}

function FieldSeparator({
  children,
  className,
  ...props
}: React.ComponentProps<"div"> & {
  children?: React.ReactNode
}) {
  return (
    <div
      data-slot="field-separator"
      data-content={!!children}
      className={cn(
        "relative -my-2 h-5 text-sm group-data-[variant=outline]/field-group:-mb-2",
        className
      )}
      {...props}
    >
      <Separator className="absolute inset-0 top-1/2" />
      {children && (
        <span
          className="relative mx-auto block w-fit bg-background px-2 text-muted-foreground"
          data-slot="field-separator-content"
        >
          {children}
        </span>
      )}
    </div>
  )
}

function FieldError({
  className,
  children,
  errors,
  ...props
}: React.ComponentProps<"div"> & {
  errors?: Array<{ message?: string } | undefined>
}) {
  const content = useMemo(() => {
    if (children) {
      return children
    }

    if (!errors?.length) {
      return null
    }

    const uniqueErrors = [
      ...new Map(errors.map((error) => [error?.message, error])).values(),
    ]

    if (uniqueErrors?.length == 1) {
      return uniqueErrors[0]?.message
    }

    return (
      <ul className="ml-4 flex list-disc flex-col gap-1">
        {uniqueErrors.map(
          (error, index) =>
            error?.message && <li key={index}>{error.message}</li>
        )}
      </ul>
    )
  }, [children, errors])

  if (!content) {
    return null
  }

  return (
    <div
      role="alert"
      data-slot="field-error"
      className={cn("text-sm font-normal text-destructive", className)}
      {...props}
    >
      {content}
    </div>
  )
}

export {
  Field,
  FieldLabel,
  FieldDescription,
  FieldError,
  FieldGroup,
  FieldLegend,
  FieldSeparator,
  FieldSet,
  FieldContent,
  FieldTitle,
}

Cập nhật các đường dẫn import cho phù hợp với thiết lập dự án của bạn.

Cách dùng

import {
  Field,
  FieldContent,
  FieldDescription,
  FieldError,
  FieldGroup,
  FieldLabel,
  FieldLegend,
  FieldSeparator,
  FieldSet,
  FieldTitle,
} from "@/components/ui/field"
<FieldSet>
  <FieldLegend>Profile</FieldLegend>
  <FieldDescription>This appears on invoices and emails.</FieldDescription>
  <FieldGroup>
    <Field>
      <FieldLabel htmlFor="name">Full name</FieldLabel>
      <Input id="name" autoComplete="off" placeholder="Evil Rabbit" />
      <FieldDescription>This appears on invoices and emails.</FieldDescription>
    </Field>
    <Field>
      <FieldLabel htmlFor="username">Username</FieldLabel>
      <Input id="username" autoComplete="off" aria-invalid />
      <FieldError>Choose another username.</FieldError>
    </Field>
    <Field orientation="horizontal">
      <Switch id="newsletter" />
      <FieldLabel htmlFor="newsletter">Subscribe to the newsletter</FieldLabel>
    </Field>
  </FieldGroup>
</FieldSet>

Kết hợp

Field

Một điều khiển duy nhất với nhãn, văn bản trợ giúp và xác thực.

Field
├── FieldLabel
├── Input / Textarea / Switch / Select
├── FieldDescription
└── FieldError

FieldGroup

Các trường liên quan trong một nhóm. Dùng FieldSeparator giữa các phần khi cần.

FieldGroup
├── Field
│   ├── FieldLabel
│   ├── Input / Textarea / Switch / Select
│   ├── FieldDescription
│   └── FieldError
├── FieldSeparator
└── Field
    ├── FieldLabel
    └── Input / Textarea / Switch / Select

FieldSet

Nhóm ngữ nghĩa với một chú thích và mô tả, thường chứa một FieldGroup.

FieldSet
├── FieldLegend
├── FieldDescription
└── FieldGroup
    ├── Field
    │   ├── FieldLabel
    │   ├── Input / Textarea / Switch / Select
    │   ├── FieldDescription
    │   └── FieldError
    └── Field
        ├── FieldLabel
        └── Input / Textarea / Switch / Select

Giải phẫu

Nhóm Field được thiết kế để tạo các form dễ truy cập. Một trường điển hình được cấu trúc như sau:

<Field>
  <FieldLabel htmlFor="input-id">Label</FieldLabel>
  {/* Input, Select, Switch, etc. */}
  <FieldDescription>Optional helper text.</FieldDescription>
  <FieldError>Validation message.</FieldError>
</Field>
  • Field là wrapper cốt lõi cho một trường duy nhất.
  • FieldContent là một cột flex nhóm nhãn và mô tả. Không bắt buộc nếu bạn không có mô tả.
  • Bao bọc các trường liên quan bằng FieldGroup, và dùng FieldSet với FieldLegend để nhóm ngữ nghĩa.

Form

Xem tài liệu Form để xây dựng form với thành phần FieldReact Hook Form, Tanstack Form, hoặc Formisch.

Ví dụ

Input

import {
  Field,
  FieldDescription,

Textarea

import {
  Field,
  FieldDescription,

Select

import {
  Field,
  FieldDescription,

Slider

"use client"

import * as React from "react"

Fieldset

import {
  Field,
  FieldDescription,

Checkbox

import { Checkbox } from "@/components/ui/checkbox"
import {
  Field,

Radio

import {
  Field,
  FieldDescription,

Switch

import { Field, FieldLabel } from "@/components/ui/field"
import { Switch } from "@/components/ui/switch"

Thẻ lựa chọn

Bao bọc các thành phần Field bên trong FieldLabel để tạo các nhóm trường có thể chọn. Cách này hoạt động với các thành phần RadioItem, CheckboxSwitch.

import {
  Field,
  FieldContent,

Nhóm trường

Xếp chồng các thành phần Field bằng FieldGroup. Thêm FieldSeparator để chia chúng.

import { Checkbox } from "@/components/ui/checkbox"
import {
  Field,

RTL

Để bật hỗ trợ RTL trong Neobrutalism, hãy xem hướng dẫn cấu hình RTL.

"use client"

import * as React from "react"

Bố cục đáp ứng

  • Trường dọc: Hướng mặc định xếp chồng nhãn, điều khiển và văn bản trợ giúp — lý tưởng cho các bố cục ưu tiên di động.
  • Trường ngang: Đặt orientation="horizontal" trên Field để căn nhãn và điều khiển cạnh nhau. Kết hợp với FieldContent để giữ các mô tả được căn chỉnh.
  • Trường đáp ứng: Đặt orientation="responsive" để có các bố cục cột tự động bên trong các phần tử cha nhận biết container. Áp dụng các lớp @container/field-group trên FieldGroup để chuyển đổi hướng tại các breakpoint cụ thể.
import { Button } from "@/components/ui/button"
import {
  Field,

Xác thực và lỗi

  • Thêm data-invalid vào Field để chuyển toàn bộ khối sang trạng thái lỗi.
  • Thêm aria-invalid trên chính ô nhập cho các công nghệ hỗ trợ.
  • Render FieldError ngay sau điều khiển hoặc bên trong FieldContent để giữ các thông báo lỗi được căn chỉnh với trường.
<Field data-invalid>
  <FieldLabel htmlFor="email">Email</FieldLabel>
  <Input id="email" type="email" aria-invalid />
  <FieldError>Enter a valid email address.</FieldError>
</Field>

Khả năng truy cập

  • FieldSetFieldLegend giữ các điều khiển liên quan được nhóm lại cho người dùng bàn phím và công nghệ hỗ trợ.
  • Field xuất ra role="group" để các điều khiển lồng nhau kế thừa việc gắn nhãn từ FieldLabelFieldLegend khi kết hợp.
  • Áp dụng FieldSeparator một cách hạn chế để đảm bảo trình đọc màn hình gặp các ranh giới phần rõ ràng.

Khả năng truy cập

Bộ Field đặt ngữ nghĩa lên trước: FieldSetFieldLegend render fieldset/legend native, Field render role="group", còn FieldError render role="alert" để các thông báo kiểm tra hợp lệ được đọc lên ngay khi xuất hiện.

  • Luôn ghép mỗi FieldLabel với điều khiển của nó qua htmlFor/id — bấm vào nhãn sẽ focus vào điều khiển và trình đọc màn hình đọc lên đúng mối liên hệ đó.
  • Khi dữ liệu không hợp lệ, hãy đặt aria-invalid lên chính điều khiển; data-invalid trên Field chỉ để tạo kiểu cho trạng thái lỗi.
  • Hành vi bàn phím thuộc về các điều khiển bên trong — các lớp bọc không thêm điểm dừng focus hay tổ hợp phím nào của riêng chúng.

Tham chiếu API

FieldSet

Container render một fieldset ngữ nghĩa với các thiết lập khoảng cách sẵn.

PropTypeDefault
classNamestring
<FieldSet>
  <FieldLegend>Delivery</FieldLegend>
  <FieldGroup>{/* Fields */}</FieldGroup>
</FieldSet>

FieldLegend

Phần tử chú thích cho một FieldSet. Chuyển sang biến thể label để căn với kích thước nhãn.

PropTypeDefault
variant"legend" | "label""legend"
classNamestring
<FieldLegend variant="label">Notification Preferences</FieldLegend>

FieldLegend có hai biến thể: legendlabel. Biến thể label áp dụng kích thước và căn chỉnh của nhãn. Hữu ích nếu bạn có các FieldSet lồng nhau.

FieldGroup

Wrapper bố cục xếp chồng các thành phần Field và bật container query cho các hướng đáp ứng.

PropTypeDefault
classNamestring
<FieldGroup className="@container/field-group flex flex-col gap-6">
  <Field>{/* ... */}</Field>
  <Field>{/* ... */}</Field>
</FieldGroup>

Field

Wrapper cốt lõi cho một trường duy nhất. Cung cấp kiểm soát hướng, tạo kiểu trạng thái không hợp lệ và khoảng cách.

PropTypeDefault
orientation"vertical" | "horizontal" | "responsive""vertical"
classNamestring
data-invalidboolean
<Field orientation="horizontal">
  <FieldLabel htmlFor="remember">Remember me</FieldLabel>
  <Switch id="remember" />
</Field>

FieldContent

Cột flex nhóm điều khiển và các mô tả khi nhãn nằm bên cạnh điều khiển. Không bắt buộc nếu bạn không có mô tả.

PropTypeDefault
classNamestring
<Field>
  <Checkbox id="notifications" />
  <FieldContent>
    <FieldLabel htmlFor="notifications">Notifications</FieldLabel>
    <FieldDescription>Email, SMS, and push options.</FieldDescription>
  </FieldContent>
</Field>

FieldLabel

Nhãn được tạo kiểu cho cả ô nhập trực tiếp và các phần tử con Field lồng nhau.

PropTypeDefault
classNamestring
asChildbooleanfalse
<FieldLabel htmlFor="email">Email</FieldLabel>

FieldTitle

Render một tiêu đề với kiểu nhãn bên trong FieldContent.

PropTypeDefault
classNamestring
<FieldContent>
  <FieldTitle>Enable Touch ID</FieldTitle>
  <FieldDescription>Unlock your device faster.</FieldDescription>
</FieldContent>

FieldDescription

Slot văn bản trợ giúp tự động cân bằng các dòng dài trong các bố cục ngang.

PropTypeDefault
classNamestring
<FieldDescription>We never share your email with anyone.</FieldDescription>

FieldSeparator

Dấu phân chia trực quan để phân tách các phần bên trong một FieldGroup. Chấp nhận nội dung nội tuyến tùy chọn.

PropTypeDefault
classNamestring
<FieldSeparator>Or continue with</FieldSeparator>

FieldError

Container lỗi dễ truy cập chấp nhận các phần tử con hoặc một mảng errors (ví dụ: từ react-hook-form).

PropTypeDefault
errorsArray<{ message?: string } | undefined>
classNamestring
<FieldError errors={errors.username} />

Khi mảng errors chứa nhiều thông báo, thành phần tự động render một danh sách.

FieldError cũng chấp nhận các vấn đề được tạo ra bởi bất kỳ trình xác thực nào triển khai Standard Schema, bao gồm Zod, Valibot và ArkType. Truyền trực tiếp mảng issues từ kết quả schema để render một danh sách lỗi thống nhất trên các thư viện.