# Textarea

A native textarea with the same field join as Input, and an optional height that grows with the content.

Textarea renders a real `<textarea>` with the same field join as
[`Input`](/components/input), and can grow its height to fit what is in it.
Useful for a message or a note; if the box should keep a fixed height, leave
`autoSize` off.

::component-preview{name="TextareaPreview"}
```vue
<template>
  <Field.Root
    class="group flex w-[17rem] flex-col gap-1.5"
    :options="{ name: 'message' }"
  >
    <div :class="box">
      <div :class="stack">
        <span :class="line">
          <Textarea
            v-model="message"
            :class="control"
            auto-size
            :max-rows="6"
            rows="2"
            placeholder=" "
          />
        </span>
        <Label :class="label">Message</Label>
      </div>
    </div>

    <Field.Description :class="hint">
      Grows to six rows, then scrolls.
    </Field.Description>
  </Field.Root>
</template>

<script setup lang="ts">
import { ref } from 'vue'
import { Field, Label, Textarea } from '@maas/mirror/vue'
const message = ref('')

const box = [
  'relative isolate flex min-h-24 max-h-48 w-full items-start overflow-y-auto scrollbar-none',
  'rounded-component-lg border-surface border-2 p-[0.875rem]',
  'cursor-text transition-all duration-100 ease-linear [&_*]:transition-all [&_*]:duration-100 [&_*]:ease-linear',
  'focus-within:py-[0.375rem] has-[textarea:not(:placeholder-shown)]:py-[0.375rem]',
  '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]:border-disabled-subtle group-data-[disabled=true]:cursor-not-allowed',
].join(' ')

const stack = [
  'group/stack flex max-h-full w-full flex-col-reverse',
  'items-center justify-center gap-0 px-1',
  'focus-within:gap-1 has-[textarea:not(:placeholder-shown)]:gap-1',
].join(' ')

const line = [
  'flex w-full min-h-0 overflow-x-clip',
  'group-focus-within/stack:min-h-[1.4rem]',
  'has-[textarea:not(:placeholder-shown)]:min-h-[1.4rem]',
].join(' ')

const control = [
  'type-component-lg text-surface w-full bg-transparent outline-none',
  'scrollbar-none placeholder:text-transparent',
  '[--mirror-textarea-min-height:2.625rem] [--mirror-textarea-resize:none]',
  'group-[[data-invalid=true]:not([data-focused=true])]:text-danger-on-muted',
  'data-[disabled=true]: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-[textarea:not(:placeholder-shown)]/stack:h-auto group-has-[textarea:not(:placeholder-shown)]/stack:text-[0.6875rem] group-has-[textarea:not(:placeholder-shown)]/stack:[--mirror-label-cursor:auto]',
  'group-[[data-invalid=true]:not([data-focused=true])]:text-danger-muted',
  'group-data-[disabled=true]:text-disabled-muted',
].join(' ')

const hint = 'type-component-2xs text-surface-muted'
</script>
```
::

## Usage guidelines

- `maxRows` is worked out from the resolved `line-height`. A `line-height` of
  `normal` is a keyword rather than a length, so the height of one line is
  measured off the element’s own typography instead, which costs a layout read
  the first time. An explicit `line-height` saves it.
- Set `id` on `Field.Root` rather than on the textarea, because `Label` points
  its `for` at the field’s control ID and an `id` here leaves it pointing at
  nothing.

## Anatomy

Render it inside a `Field.Root`, or on its own.

::component-anatomy
---
parts:
  - name: Textarea
    required: true
    description: Renders a native <textarea>, joined to the surrounding Field.
---
::

```vue
<script setup lang="ts">
import { Field, Label, Textarea } from '@maas/mirror/vue'
</script>

<template>
  <Textarea v-model="notes" />

  <Field.Root :options="{ name: 'message' }">
    <Label>Message</Label>
    <Textarea v-model="message" auto-size />
  </Field.Root>
</template>
```

## Examples

### Controlled and uncontrolled

`defaultValue` seeds the value and leaves `Textarea` owning it, while `v-model`
hands it to you, and which of the two applies is
[decided once at mount](/components/composition#controlled-and-uncontrolled).

```vue
<template>
  <Textarea default-value="Dear Robin," name="message" rows="4" />

  <Textarea v-model="message" rows="4" placeholder="Say something" />
</template>

<script setup lang="ts">
const message = ref('')
</script>
```

### Auto-sizing

`autoSize` grows the element to fit its content, measuring on mount and again
whenever the value, `autoSize` or `maxRows` change. Each measurement releases
the height for a frame and reads `scrollHeight` off the element itself, then
publishes it as a custom property the stylesheet applies.

```vue
<template>
  <Textarea v-model="message" class="textarea" auto-size rows="2" />
</template>

<style>
.textarea {
  --mirror-textarea-resize: none;
  line-height: 1.5rem;
}
</style>
```

The element never gets an inline `height`, so your own CSS keeps the last word.
A width change or a font swap is not a value change, and leaves the last
measurement standing until the next keystroke.

### Capping the growth

`maxRows` caps the growth and lets the element scroll past the cap, working the
height out from the resolved `line-height` plus the block padding and border.
Where `line-height` resolves to `normal`, one line is measured off a probe
carrying the element’s own typography.

```vue
<template>
  <Textarea
    v-model="message"
    class="textarea"
    auto-size
    :max-rows="6"
    rows="2"
  />
</template>

<style>
.textarea {
  --mirror-textarea-resize: none;
  line-height: 1.5rem;
  padding: 0.5rem 0.75rem;
}
</style>
```

### Inside a field

The join is [`Input`](/components/input)’s, unchanged: the textarea takes the
field’s control ID, `name` and flags, and reports its value, its focus and its
blur back.

```vue
<template>
  <Field.Root
    :options="{ name: 'message', required: true, validationMode: 'onBlur' }"
  >
    <Label>Message</Label>
    <Textarea v-model="message" auto-size :max-rows="8" />
    <Field.Description>Markdown is supported.</Field.Description>
    <Field.Error match="valueMissing">Write something first.</Field.Error>
  </Field.Root>
</template>
```

## API reference

**Standalone.** Ordinary props, no store. As on `Input`, attributes not mirrored
into props (`rows`, `cols`, `placeholder`, `maxlength`, `wrap`) fall through to
the element unchanged.

### `Textarea`

Renders a `<textarea>`.

#### Props

::docs-table
---
columns:
  - label: Prop
  - label: Type
  - label: Default
rows:
  - items:
      - label: id
        description: 'The element’s DOM ID; [set it on `Field.Root`](#usage-guidelines) instead.'
      - label: string
      - label: the field’s `controlId`, else generated
        plaintext: true
  - items:
      - label: modelValue
        description: The value. `v-model`.
      - label: 'string | number'
      - label: undefined
  - items:
      - label: defaultValue
        description: 'Initial value while [uncontrolled](#controlled-and-uncontrolled).'
      - label: 'string | number'
      - label: ''''''
  - items:
      - label: autoSize
        description: '[Grows the element](#auto-sizing) to fit its content.'
      - label: boolean
      - label: 'false'
  - items:
      - label: maxRows
        description: 'Caps [auto-sizing](#capping-the-growth) at this many rows, then scrolls.'
      - label: number
      - label: undefined
  - items:
      - label: name
        description: Form field name. Falls back to the field’s `name`.
      - label: string
      - label: undefined
  - items:
      - label: disabled
        description: Falls back to the field’s `disabled`, then `false`.
      - label: boolean
      - label: undefined
  - items:
      - label: readOnly
        description: Falls back to the field’s `readOnly`, then `false`.
      - label: boolean
      - label: undefined
  - items:
      - label: required
        description: Falls back to the field’s `required`, then `false`.
      - label: boolean
      - label: undefined
---
::

#### Emits

::docs-table
---
columns:
  - label: Emit
  - label: Payload
rows:
  - items:
      - label: update:modelValue
        description: On every `input` event, controlled or not.
      - label: 'string | number'
  - items:
      - label: focus
        description: Focus enters the textarea.
      - label: FocusEvent
  - items:
      - label: blur
        description: Focus leaves the textarea.
      - label: FocusEvent
---
::

#### Slot props

None. `Textarea` exposes `focus()`, `blur()` and `select()` on its instance
instead.

#### Data attributes

The [field state set](/components/styling#the-field-state-set), plus one addition.

::data-attributes
::

#### CSS variables

::css-variables
::

`--mirror-textarea-min-height` and `--mirror-textarea-resize` apply to every
textarea. The auto-sizing rules (`block-size`, `max-block-size` and
`overflow-y`) apply only under `[data-auto-size='true']`, and read private
properties the component writes.

#### Errors

::docs-table
---
columns:
  - label: Code
rows:
  - items:
      - label: duplicate_field_control
        description: A second control registers into the same `Field.Root`. Thrown by [`Field.Root`](/components/field), not by `Textarea`.
---
::

## Accessibility

Native throughout: no roles and no key handlers, so Enter inserts a newline and
never submits the form. Inside a `Field` it adds `aria-describedby`,
`aria-invalid`, `aria-required` and `aria-readonly`, and nothing else.
