<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>
Usage
import { FormLayout } from '@hina-ui/vue'
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.
<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>
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.
<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>
Columns
columns takes 1 to 4; give a field that should fill a row a spanning class such as sm:col-span-2.
<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>
Sections
Several layouts in one form, each with its own title and description, separated by the form’s spacing.
<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>
Disabling a group
disabled disables every field in the group; when the whole form is disabled, every group follows.
<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>
Behavior
- The grid splits into
columnsfrom thesmbreakpoint up and stays a single column below it; four columns first fold to two betweensmandlg. - 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. disabledis written on thefieldsetand passed to the fields in the group.
Accessibility
- The root is a
fieldsetand the title alegend, so assistive technology reads the group name before each field. - The description is linked to the
fieldsetthrougharia-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 |