# 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.

::component-preview{name="FieldsetPreview"}
```vue
<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.Root` sets it back to `0`, and
  `--mirror-fieldset-min-inline-size` is 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, turn `nativeLegend` off and keep the
  legend in normal flow.
- `disabled` reaches the controls through the browser, not through Mirror, so
  the controls inside do not write `data-disabled` themselves. Style them from
  the fieldset instead, the way the demo does.

## Anatomy

A root and a legend, with the fields in between.

::component-anatomy
---
parts:
  - name: Fieldset.Root
    required: true
    description: Renders a <fieldset>. Owns the disabled state.
    children:
      - name: Fieldset.Legend
        description: Renders a <legend> that names the group.
---
::

```vue
<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.

```vue
<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.

```vue
<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.

```vue
<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.

```vue
<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.

```vue
<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

::docs-table
---
columns:
  - label: Prop
  - label: Type
  - label: Default
rows:
  - items:
      - label: id
        description: The instance ID, and the base for the generated legend ID.
      - label: string
      - label: generated
        plaintext: true
  - items:
      - label: options
        description: The bundled configuration below.
      - label: FieldsetOptions
      - label: '{}'
---
::

#### Options

::docs-table
---
columns:
  - label: Option
  - label: Type
  - label: Default
rows:
  - items:
      - label: disabled
        description: 'Sets the native attribute, which [disables every control inside](#turning-a-group-off).'
      - label: boolean
      - label: 'false'
  - items:
      - label: nativeLegend
        description: 'Renders the legend as a [`<legend>`](#when-the-legend-cannot-be-a-legend).'
      - label: boolean
      - label: 'true'
---
::

#### Slot props

::docs-table
---
columns:
  - label: Prop
  - label: Type
rows:
  - items:
      - label: disabled
        description: Mirrors `data-disabled`.
      - label: boolean
---
::

### `Fieldset.Legend`

Renders a `<legend>`, or a `<div>` while `nativeLegend` is `false`.

#### Props

::docs-table
---
columns:
  - label: Prop
  - label: Type
  - label: Default
rows:
  - items:
      - label: id
        description: The ID of the fieldset this legend names. Only needed outside `Fieldset.Root`.
      - label: string
      - label: injected
        plaintext: true
---
::

#### Slot props

::docs-table
---
columns:
  - label: Prop
  - label: Type
rows:
  - items:
      - label: disabled
        description: Mirrors `data-disabled`.
      - label: boolean
---
::

### Composable

`useMirrorFieldset(id)` reaches a fieldset from anywhere in the app.

::docs-table
---
columns:
  - label: Key
  - label: Type
rows:
  - items:
      - label: disabled
        description: Mirrors `data-disabled`.
      - label: ComputedRef<boolean>
        escape: true
  - items:
      - label: legendId
        description: The ID a non-native legend renders.
      - label: ComputedRef<string>
        escape: true
  - items:
      - label: labelledBy
        description: What the root points `aria-labelledby` at, or undefined while the legend is native.
      - label: ComputedRef<string | undefined>
        escape: true
---
::

### Data attributes

::data-attributes
::

### CSS variables

::css-variables
::

### Errors

::docs-table
---
columns:
  - label: Code
rows:
  - items:
      - label: missing_fieldset_context
        description: '`Fieldset.Legend` rendered outside `Fieldset.Root` without an `id`.'
---
::

## 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.
