FormLayout

Groups fields into a grid with a title and description.

Contact

A name and at least one way to reach you

<script setup lang="ts">
  import { reactive, ref } from 'vue'
  import * as v from 'valibot'
  import { Button, Form, FormField, FormLayout, Input, Text } from '@hina-ui/vue'

  const schema = v.object({
    firstName: v.pipe(v.string('Enter a first name'), v.nonEmpty('Enter a first name')),
    lastName: v.pipe(v.string('Enter a last name'), v.nonEmpty('Enter a last name')),
    email: v.pipe(
      v.string('Enter an email'),
      v.nonEmpty('Enter an email'),
      v.email('The email is not valid'),
    ),
    phone: v.string(),
  })

  const values = reactive({ firstName: '', lastName: '', email: '', phone: '' })
  const saved = ref('')

  async function save(data: unknown) {
    await new Promise(resolve => setTimeout(resolve, 600))
    saved.value = JSON.stringify(data)
  }
</script>

<template>
  <Form
    v-slot="{ submitting }"
    :values="values"
    :rules="schema"
    class="w-full max-w-lg"
    @submit="save"
  >
    <FormLayout
      legend="Contact"
      description="A name and at least one way to reach you"
      :columns="2"
    >
      <FormField name="firstName" label="First name" required>
        <Input v-model="values.firstName" />
      </FormField>
      <FormField name="lastName" label="Last name" required>
        <Input v-model="values.lastName" />
      </FormField>
      <FormField name="email" label="Email" required class="sm:col-span-2">
        <Input v-model="values.email" type="email" />
      </FormField>
      <FormField name="phone" label="Phone" class="sm:col-span-2">
        <Input v-model="values.phone" type="tel" />
      </FormField>
    </FormLayout>
    <Button type="submit" :loading="submitting" class="self-start">Save</Button>
    <Text v-if="saved" tone="muted" size="sm">Saved: {{ saved }}</Text>
  </Form>
</template>
vue

Usage

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

The form layout puts a set of related fields into a fieldset: legend names the group, description explains it, and columns sets how many columns the fields take, collapsing to one on narrow screens. It usually sits inside a Form to split a long form into parts, and works on its own as well.

Account

Used to sign in

<script setup lang="ts">
  import { ref } from 'vue'
  import { FormField, FormLayout, Input } from '@hina-ui/vue'

  const name = ref('')
  const email = ref('')
</script>

<template>
  <FormLayout legend="Account" description="Used to sign in" class="w-80">
    <FormField label="Username">
      <Input v-model="name" />
    </FormField>
    <FormField label="Email">
      <Input v-model="email" type="email" />
    </FormField>
  </FormLayout>
</template>
vue

Examples

Aligned fields

Set orientation, label-width and description-placement once on the layout to configure its FormFields. A field can override any setting; nested layouts inherit the outer settings and scope their own overrides to their descendants. columns still means the number of fields per row, not label/control columns.

In responsive mode each field stacks below 32rem of available width. A field without a label or label-side description uses the full row, which suits a Switch or Checkbox that carries its own label.

Profile settings

Shown on your public profile.

Use an address that can receive notifications.

<script setup lang="ts">
  import { ref } from 'vue'
  import { FormField, FormLayout, Input, Switch } from '@hina-ui/vue'
  const name = ref('Hina')
  const email = ref('hello@example.com')
  const sync = ref(true)
</script>
<template>
  <FormLayout
    legend="Profile settings"
    orientation="responsive"
    label-width="9rem"
    description-placement="label"
    class="w-full"
  >
    <FormField label="Display name" description="Shown on your public profile.">
      <Input v-model="name" />
    </FormField>
    <FormField
      label="Email address"
      description="Use an address that can receive notifications."
      description-placement="control"
    >
      <Input v-model="email" type="email" />
    </FormField>
    <FormField>
      <Switch
        v-model="sync"
        control-placement="end"
        block
        description="Continue where you left off on another device."
      >
        Sync reading progress
      </Switch>
    </FormField>
  </FormLayout>
</template>
vue

Columns

columns takes 1 to 4; give a field that should fill a row a spanning class such as sm:col-span-2.

Shipping address
<script setup lang="ts">
  import { reactive } from 'vue'
  import { FormField, FormLayout, Input } from '@hina-ui/vue'

  const values = reactive({ city: '', state: '', zip: '', street: '' })
</script>

<template>
  <FormLayout legend="Shipping address" :columns="3" class="w-full max-w-lg">
    <FormField label="City">
      <Input v-model="values.city" />
    </FormField>
    <FormField label="State">
      <Input v-model="values.state" />
    </FormField>
    <FormField label="ZIP">
      <Input v-model="values.zip" />
    </FormField>
    <FormField label="Street address" class="sm:col-span-3">
      <Input v-model="values.street" />
    </FormField>
  </FormLayout>
</template>
vue

Sections

Several layouts in one form, each with its own title and description, separated by the form’s spacing.

Profile

Shown on your profile page

Notifications

Let you know about replies and new followers

<script setup lang="ts">
  import { reactive, ref } from 'vue'
  import * as v from 'valibot'
  import { Button, Form, FormField, FormLayout, Input, Switch, Text, Textarea } from '@hina-ui/vue'

  const schema = v.object({
    name: v.pipe(v.string('Enter a nickname'), v.nonEmpty('Enter a nickname')),
    bio: v.pipe(v.string(), v.maxLength(80, 'Keep the bio within 80 characters')),
    notifyEmail: v.boolean(),
    notifyPush: v.boolean(),
  })

  const values = reactive({ name: '', bio: '', notifyEmail: true, notifyPush: false })
  const saved = ref('')

  async function save(data: unknown) {
    await new Promise(resolve => setTimeout(resolve, 600))
    saved.value = JSON.stringify(data)
  }
</script>

<template>
  <Form v-slot="{ submitting }" :values="values" :rules="schema" class="w-80" @submit="save">
    <FormLayout legend="Profile" description="Shown on your profile page">
      <FormField name="name" label="Nickname" required>
        <Input v-model="values.name" />
      </FormField>
      <FormField name="bio" label="Bio">
        <Textarea v-model="values.bio" />
      </FormField>
    </FormLayout>
    <FormLayout legend="Notifications" description="Let you know about replies and new followers">
      <FormField name="notifyEmail">
        <Switch v-model="values.notifyEmail">Email</Switch>
      </FormField>
      <FormField name="notifyPush">
        <Switch v-model="values.notifyPush">Push</Switch>
      </FormField>
    </FormLayout>
    <Button type="submit" :loading="submitting" class="self-start">Save</Button>
    <Text v-if="saved" tone="muted" size="sm">Saved: {{ saved }}</Text>
  </Form>
</template>
vue

Disabling a group

disabled disables every field in the group; when the whole form is disabled, every group follows.

Account
<script setup lang="ts">
  import { reactive, ref } from 'vue'
  import { FormField, FormLayout, Input, Stack, Switch } from '@hina-ui/vue'

  const values = reactive({ name: 'Hoshimi Shion', email: 'shion@example.com' })
  const locked = ref(true)
</script>

<template>
  <Stack gap="md" align="stretch" class="w-80">
    <Switch v-model="locked">Lock account details</Switch>
    <FormLayout legend="Account" :disabled="locked">
      <FormField label="Username">
        <Input v-model="values.name" />
      </FormField>
      <FormField label="Email">
        <Input v-model="values.email" type="email" />
      </FormField>
    </FormLayout>
  </Stack>
</template>
vue

Behavior

  • The grid splits into columns from the sm breakpoint up and stays a single column below it; four columns first fold to two between sm and lg.
  • Fields in the group still read their errors from the form through name; the layout does not change the relation between fields and the form.
  • disabled is written on the fieldset and passed to the fields in the group.

Accessibility

  • The root is a fieldset and the title a legend, so assistive technology reads the group name before each field.
  • The description is linked to the fieldset through aria-describedby.

API

Props

Prop
Type
Default
Description
legend
string
—
Title of the group
description
string
—
Description text
columns
1 | 2 | 3 | 4
1
Number of grid columns
orientation
'vertical' | 'horizontal' | 'responsive'
'vertical'
Default layout for descendant fields
descriptionPlacement
'label' | 'control'
'control'
Default description placement for descendant fields
labelWidth
string | number
'10rem'
Shared label column width: CSS length or pixels
disabled
boolean
false
Whether the whole group is disabled
class
string
—
Classes appended to the root element

Slots

Slot
Description
default
The fields
legend
Title content
description
Description content