FormLayout 表单布局

把字段分组排成栅格,带有标题与说明。

联系人

姓名与至少一种联系方式

'use client'

import { useState } from 'react'
import * as v from 'valibot'
import { Button, Form, FormField, FormLayout, Input, Text } from '@hina-ui/react'

const schema = v.object({
  lastName: v.pipe(v.string('请输入姓氏'), v.nonEmpty('请输入姓氏')),
  firstName: v.pipe(v.string('请输入名字'), v.nonEmpty('请输入名字')),
  email: v.pipe(v.string('请输入邮箱'), v.nonEmpty('请输入邮箱'), v.email('邮箱格式不正确')),
  phone: v.string(),
})

export default function Demo() {
  const [values, setValues] = useState({ lastName: '', firstName: '', email: '', phone: '' })
  const [saved, setSaved] = useState('')

  async function save(data: unknown) {
    await new Promise(resolve => setTimeout(resolve, 600))
    setSaved(JSON.stringify(data))
  }

  return (
    <Form values={values} rules={schema} className="w-full max-w-lg" onSubmit={save}>
      {({ submitting }) => (
        <>
          <FormLayout legend="联系人" description="姓名与至少一种联系方式" columns={2}>
            <FormField name="lastName" label="姓" required>
              <Input
                value={values.lastName}
                onValueChange={lastName => setValues({ ...values, lastName })}
              />
            </FormField>
            <FormField name="firstName" label="名" required>
              <Input
                value={values.firstName}
                onValueChange={firstName => setValues({ ...values, firstName })}
              />
            </FormField>
            <FormField name="email" label="邮箱" required className="sm:col-span-2">
              <Input
                value={values.email}
                onValueChange={email => setValues({ ...values, email })}
                type="email"
              />
            </FormField>
            <FormField name="phone" label="电话" className="sm:col-span-2">
              <Input
                value={values.phone}
                onValueChange={phone => setValues({ ...values, phone })}
                type="tel"
              />
            </FormField>
          </FormLayout>
          <Button type="submit" loading={submitting} className="self-start">
            保存
          </Button>
          {saved && (
            <Text tone="muted" size="sm">
              已保存:{saved}
            </Text>
          )}
        </>
      )}
    </Form>
  )
}
tsx

用法

import { FormLayout } from '@hina-ui/react'
ts

表单布局把一组相关的字段放进一个 fieldset,legend 是这组字段的标题,description 是说明;columns 决定字段排成几列,窄屏时自动收成一列。它通常放在 Form 里,把长表单分成几个部分;单独使用也可以。

账号

登录时使用

'use client'

import { useState } from 'react'
import { FormField, FormLayout, Input } from '@hina-ui/react'

export default function Demo() {
  const [name, setName] = useState('')
  const [email, setEmail] = useState('')

  return (
    <FormLayout legend="账号" description="登录时使用" className="w-80">
      <FormField label="用户名">
        <Input value={name} onValueChange={setName} />
      </FormField>
      <FormField label="邮箱">
        <Input value={email} onValueChange={setEmail} type="email" />
      </FormField>
    </FormLayout>
  )
}
tsx

示例

标签列对齐

在布局上统一设置 orientation、labelWidth 和 descriptionPlacement,内部 FormField 会继承这些设置,也可以逐项覆盖。嵌套 FormLayout 继承外层配置,其覆盖值只作用于自己的后代。columns 仍表示一行放几个字段,不表示标签与控件的列数。

响应式模式下,每个字段的可用宽度小于 32rem 时改为纵向排列。没有标签和标签侧说明的字段占满整行,适合包装自带文案的 Switch 或 Checkbox。

个人资料设置

显示在你的公开个人资料中。

使用可以接收通知的邮箱地址。

'use client'

import { useState } from 'react'
import { FormField, FormLayout, Input, Switch } from '@hina-ui/react'

export default function Demo() {
  const [name, setName] = useState('Hina')
  const [email, setEmail] = useState('hello@example.com')
  const [sync, setSync] = useState(true)

  return (
    <FormLayout
      legend="个人资料设置"
      orientation="responsive"
      labelWidth="9rem"
      descriptionPlacement="label"
      className="w-full"
    >
      <FormField label="显示名称" description="显示在你的公开个人资料中。">
        <Input value={name} onValueChange={setName} />
      </FormField>
      <FormField
        label="邮箱地址"
        description="使用可以接收通知的邮箱地址。"
        descriptionPlacement="control"
      >
        <Input value={email} onValueChange={setEmail} type="email" />
      </FormField>
      <FormField>
        <Switch
          checked={sync}
          onCheckedChange={setSync}
          controlPlacement="end"
          block
          description="在其他设备上继续上次的阅读进度。"
        >
          同步阅读进度
        </Switch>
      </FormField>
    </FormLayout>
  )
}
tsx

多列

columns 取 1 到 4;需要占满一行的字段给它加上 sm:col-span-2 这类跨列的类。

收货地址
'use client'

import { useState } from 'react'
import { FormField, FormLayout, Input } from '@hina-ui/react'

export default function Demo() {
  const [values, setValues] = useState({ province: '', city: '', district: '', street: '' })

  return (
    <FormLayout legend="收货地址" columns={3} className="w-full max-w-lg">
      <FormField label="省">
        <Input
          value={values.province}
          onValueChange={province => setValues({ ...values, province })}
        />
      </FormField>
      <FormField label="市">
        <Input value={values.city} onValueChange={city => setValues({ ...values, city })} />
      </FormField>
      <FormField label="区">
        <Input
          value={values.district}
          onValueChange={district => setValues({ ...values, district })}
        />
      </FormField>
      <FormField label="详细地址" className="sm:col-span-3">
        <Input value={values.street} onValueChange={street => setValues({ ...values, street })} />
      </FormField>
    </FormLayout>
  )
}
tsx

分组

一个表单里放多个布局,每组各有标题与说明,表单的间距把各组隔开。

个人资料

公开显示在个人页

通知

有新的回复或者关注时提醒你

'use client'

import { useState } from 'react'
import * as v from 'valibot'
import { Button, Form, FormField, FormLayout, Input, Switch, Text, Textarea } from '@hina-ui/react'

const schema = v.object({
  name: v.pipe(v.string('请输入昵称'), v.nonEmpty('请输入昵称')),
  bio: v.pipe(v.string(), v.maxLength(80, '简介不超过 80 个字')),
  notifyEmail: v.boolean(),
  notifyPush: v.boolean(),
})

export default function Demo() {
  const [values, setValues] = useState({ name: '', bio: '', notifyEmail: true, notifyPush: false })
  const [saved, setSaved] = useState('')

  async function save(data: unknown) {
    await new Promise(resolve => setTimeout(resolve, 600))
    setSaved(JSON.stringify(data))
  }

  return (
    <Form values={values} rules={schema} className="w-80" onSubmit={save}>
      {({ submitting }) => (
        <>
          <FormLayout legend="个人资料" description="公开显示在个人页">
            <FormField name="name" label="昵称" required>
              <Input value={values.name} onValueChange={name => setValues({ ...values, name })} />
            </FormField>
            <FormField name="bio" label="简介">
              <Textarea value={values.bio} onValueChange={bio => setValues({ ...values, bio })} />
            </FormField>
          </FormLayout>
          <FormLayout legend="通知" description="有新的回复或者关注时提醒你">
            <FormField name="notifyEmail">
              <Switch
                checked={values.notifyEmail}
                onCheckedChange={notifyEmail => setValues({ ...values, notifyEmail })}
              >
                邮件
              </Switch>
            </FormField>
            <FormField name="notifyPush">
              <Switch
                checked={values.notifyPush}
                onCheckedChange={notifyPush => setValues({ ...values, notifyPush })}
              >
                推送
              </Switch>
            </FormField>
          </FormLayout>
          <Button type="submit" loading={submitting} className="self-start">
            保存
          </Button>
          {saved && (
            <Text tone="muted" size="sm">
              已保存:{saved}
            </Text>
          )}
        </>
      )}
    </Form>
  )
}
tsx

禁用一组

disabled 禁用这一组里的所有字段;表单整体禁用时,每一组也随之禁用。

账号
'use client'

import { useState } from 'react'
import { FormField, FormLayout, Input, Stack, Switch } from '@hina-ui/react'

export default function Demo() {
  const [values, setValues] = useState({ name: '星见书音', email: 'shion@example.com' })
  const [locked, setLocked] = useState(true)

  return (
    <Stack gap="md" align="stretch" className="w-80">
      <Switch checked={locked} onCheckedChange={setLocked}>
        锁定账号信息
      </Switch>
      <FormLayout legend="账号" disabled={locked}>
        <FormField label="用户名">
          <Input value={values.name} onValueChange={name => setValues({ ...values, name })} />
        </FormField>
        <FormField label="邮箱">
          <Input
            value={values.email}
            onValueChange={email => setValues({ ...values, email })}
            type="email"
          />
        </FormField>
      </FormLayout>
    </Stack>
  )
}
tsx

行为

  • 栅格从 sm 断点起按 columns 分列,更窄的屏幕上是一列;四列在 sm 到 lg 之间先收成两列。
  • 组内字段照常通过 name 从表单取得错误,布局不改变字段与表单的关系。
  • disabled 同时写到 fieldset 上并传给组内的字段。

无障碍

  • 根元素是 fieldset,标题是 legend,辅助技术会把组名读在每个字段之前。
  • 说明文字通过 aria-describedby 关联到 fieldset。

API

Props

属性
类型
默认值
说明
legend
string
—
这组字段的标题
description
string
—
说明文字
columns
1 | 2 | 3 | 4
1
栅格列数
orientation
'vertical' | 'horizontal' | 'responsive'
'vertical'
后代字段的默认布局
descriptionPlacement
'label' | 'control'
'control'
后代字段的默认说明位置
labelWidth
string | number
'10rem'
统一标签列宽:CSS 长度或像素数
disabled
boolean
false
是否禁用整组字段
className
string
—
追加至根元素的类名

内容属性

属性
说明
children
字段
legend
标题内容
description
说明内容