Fieldset
Groups related controls under one legend, and turns all of them off at once.
Fieldset puts a heading on a group of controls and lets you disable the whole group in one go. Reach for it when several fields belong together, like an address or a set of payment details, and the person filling the form should see that they do.
<template>
<div class="flex w-[19rem] flex-col gap-8">
<Fieldset.Root :class="fieldset">
<Fieldset.Legend :class="legend">Shipping address</Fieldset.Legend>
<div class="flex flex-col gap-3">
<Field.Root
class="group flex flex-col gap-1.5"
:options="{ name: 'recipient' }"
>
<div :class="box">
<div :class="stack">
<span :class="line">
<Input
v-model="recipient"
:class="control"
placeholder=" "
autocomplete="name"
/>
</span>
<Label :class="label">Recipient</Label>
</div>
</div>
</Field.Root>
<Field.Root
class="group flex flex-col gap-1.5"
:options="{ name: 'street' }"
>
<div :class="box">
<div :class="stack">
<span :class="line">
<Input
v-model="street"
:class="control"
placeholder=" "
autocomplete="street-address"
/>
</span>
<Label :class="label">Street and number</Label>
</div>
</div>
</Field.Root>
</div>
</Fieldset.Root>
<Fieldset.Root :class="fieldset" :options="{ disabled: true }">
<Fieldset.Legend :class="legend">Billing address</Fieldset.Legend>
<div class="flex flex-col gap-3">
<Field.Root
class="group flex flex-col gap-1.5"
:options="{ name: 'billing' }"
>
<div :class="box">
<div :class="stack">
<span :class="line">
<Input :class="control" placeholder=" " />
</span>
<Label :class="label">Same as shipping</Label>
</div>
</div>
</Field.Root>
</div>
</Fieldset.Root>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { Field, Fieldset, Input, Label } from '@maas/mirror/vue'
const recipient = ref('')
const street = ref('')
const fieldset = 'group/fieldset border-0 p-0'
const legend =
'type-component-md text-surface mb-3 group-data-[disabled=true]/fieldset:text-disabled-muted'
const box = [
'relative isolate flex h-12 w-full cursor-text items-center justify-between gap-1.5',
'rounded-component-lg border-surface border-2 px-[0.875rem]',
'transition-all duration-100 ease-linear [&_*]:transition-all [&_*]:duration-100 [&_*]:ease-linear',
'outline-4 outline-transparent focus-within:focus-ring',
'group-[[data-invalid=true]:not([data-focused=true])]:border-danger-subtle',
'group-[[data-invalid=true]:not([data-focused=true])]:bg-danger-subtle',
'group-data-[disabled=true]/fieldset:border-disabled-subtle group-data-[disabled=true]/fieldset:cursor-not-allowed',
].join(' ')
const stack = [
'group/stack flex h-full max-h-full w-full flex-col-reverse',
'items-center justify-center gap-0 px-1',
'focus-within:gap-1 has-[input:not(:placeholder-shown)]:gap-1',
].join(' ')
const line = [
'relative flex h-0 w-full items-end',
'group-focus-within/stack:h-[0.9375rem]',
'has-[input:not(:placeholder-shown)]:h-[0.9375rem]',
].join(' ')
const control = [
'type-component-lg leading-[normal]! text-surface',
'block box-content h-[1lh] w-full py-1.25 -my-1.25 appearance-none bg-transparent outline-none',
'placeholder:text-transparent',
'group-data-[disabled=true]/fieldset:text-disabled-solid',
].join(' ')
const label = [
'type-component-lg leading-[normal]! text-surface-muted',
'flex h-full w-full items-center [--mirror-label-cursor:text]',
"before:absolute before:-inset-0.5 before:-z-10 before:content-['']",
'group-focus-within/stack:h-auto group-focus-within/stack:text-[0.6875rem] group-focus-within/stack:[--mirror-label-cursor:auto]',
'group-has-[input:not(:placeholder-shown)]/stack:h-auto group-has-[input:not(:placeholder-shown)]/stack:text-[0.6875rem] group-has-[input:not(:placeholder-shown)]/stack:[--mirror-label-cursor:auto]',
'group-data-[disabled=true]/fieldset:text-disabled-muted',
].join(' ')
</script>
The second group is disabled, so the browser will not let anyone type in the control inside it.
Usage guidelines
- Give every fieldset a legend. Without one a screen reader announces the group with no name at all.
- Browsers give a fieldset
min-width: min-content, which stops it shrinking inside a flex or grid parent.Fieldset.Rootsets it back to0, and--mirror-fieldset-min-inline-sizeis there if you want the old behaviour. - A native
<legend>has to be the first child of the fieldset, and a fieldset that lays its children out with flex or grid renders it in ways that differ per browser. If you need the layout, turnnativeLegendoff and keep the legend in normal flow. disabledreaches the controls through the browser, not through Mirror, so the controls inside do not writedata-disabledthemselves. Style them from the fieldset instead, the way the demo does.
Anatomy
A root and a legend, with the fields in between.
| Name | Required | Description |
|---|---|---|
| Fieldset.Root | true | Renders a <fieldset>. Owns the disabled state. |
| Fieldset.Legend | Renders a <legend> that names the group. |
<script setup lang="ts">
import { Field, Fieldset, Input, Label } from '@maas/mirror/vue'
</script>
<template>
<Fieldset.Root>
<Fieldset.Legend>Shipping address</Fieldset.Legend>
<Field.Root :options="{ name: 'recipient' }">
<Label>Recipient</Label>
<Input v-model="recipient" />
</Field.Root>
</Fieldset.Root>
</template>
Examples
Turning a group off
disabled sets the native attribute, and the browser takes every control inside
out of the tab order and stops it responding. Nothing has to be passed down.
<template>
<Fieldset.Root :options="{ disabled: sameAsShipping }">
<Fieldset.Legend>Billing address</Fieldset.Legend>
<Field.Root :options="{ name: 'billing' }">
<Label>Street and number</Label>
<Input v-model="billing" />
</Field.Root>
</Fieldset.Root>
</template>
Both parts write data-disabled, so the look of the group hangs off the root
rather than off each control. Put a named group on the root and read it from
the controls inside.
<template>
<Fieldset.Root class="group/fieldset" :options="{ disabled: true }">
<Fieldset.Legend
class="group-data-[disabled=true]/fieldset:text-disabled-muted"
>
Billing address
</Fieldset.Legend>
</Fieldset.Root>
</template>
When the legend cannot be a legend
A <legend> only counts as one while it is the first child of a <fieldset>,
and browsers place it themselves. Set nativeLegend to false and the legend
renders a <div> with an ID instead, which the root picks up as
aria-labelledby. The group is named either way.
<template>
<Fieldset.Root :options="{ nativeLegend: false }" class="flex flex-col gap-3">
<Fieldset.Legend>Shipping address</Fieldset.Legend>
<Field.Root :options="{ name: 'recipient' }">
<Label>Recipient</Label>
<Input v-model="recipient" />
</Field.Root>
</Fieldset.Root>
</template>
The option lives on the root because the root has to know how it is being named, and there is one legend per fieldset for it to answer for.
Rendering something else
Fieldset.Root renders a <fieldset> and Fieldset.Legend a <legend>, and
both take as and as-child like every other part. A root that is no longer a
<fieldset> has no native disabled to set, so pair it with
nativeLegend: false and take the group’s disabled state from the data
attribute.
<template>
<Fieldset.Root as="section" :options="{ nativeLegend: false }">
<Fieldset.Legend as="h3">Shipping address</Fieldset.Legend>
</Fieldset.Root>
</template>
Reaching a fieldset from elsewhere
Give the root an id and useMirrorFieldset reads the same state from
anywhere, which is how a summary elsewhere on the page can tell whether a group
is currently off.
<script setup lang="ts">
import { useMirrorFieldset } from '@maas/mirror/vue'
const { disabled } = useMirrorFieldset('billing')
</script>
<template>
<p v-if="disabled">Billing address is the same as shipping.</p>
</template>
API reference
Module. A bundled options object, and useMirrorFieldset(id) as the
programmatic API.
Fieldset.Root
Renders a <fieldset>.
Props
Options
Slot props
Fieldset.Legend
Renders a <legend>, or a <div> while nativeLegend is false.
Props
Slot props
Composable
useMirrorFieldset(id) reaches a fieldset from anywhere in the app.
Data attributes
CSS variables
Errors
Accessibility
A <fieldset> with a <legend> is already the accessible grouping the ARIA
group role describes, so Mirror adds no roles of its own. Screen readers read
the legend before each control inside, which is why the legend should be short.
While nativeLegend is false the legend is no longer part of that pair, so
it renders an ID and the root points aria-labelledby at it. Nothing else
changes, including where the legend sits in the reading order.
There is no keyboard behaviour. disabled takes the controls inside out of the
tab order because the browser does that for a disabled fieldset, and focus moves
on to whatever follows the group.