Input
A native input that joins a Field on its own, with the ID, the ARIA wiring and the states already right.
Input renders a real <input> and joins the surrounding
Field without being asked. Use it for a single line of
text; for anything longer, use Textarea.
We only use this to send receipts.
<template>
<div class="flex w-[17rem] flex-col gap-6">
<Field.Root
class="group flex flex-col gap-1.5"
:options="{ name: 'email', validate: validateEmail }"
>
<div :class="box">
<div :class="stack">
<span :class="line">
<Input
v-model="email"
:class="control"
type="email"
placeholder=" "
autocomplete="off"
/>
</span>
<Label :class="label">Email</Label>
</div>
</div>
<Field.Description :class="hint">
We only use this to send receipts.
</Field.Description>
<Field.Error v-slot="{ messages }" :class="error">
{{ messages.join(' ') }}
</Field.Error>
</Field.Root>
<Field.Root
class="group flex flex-col gap-1.5"
:options="{ name: 'account', disabled: true }"
>
<div :class="box">
<div :class="stack">
<span :class="line">
<Input
:class="control"
default-value="[email protected]"
placeholder=" "
/>
</span>
<Label :class="label">Account</Label>
</div>
</div>
</Field.Root>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { Field, Input, Label } from '@maas/mirror/vue'
const email = ref('')
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]:border-disabled-subtle group-data-[disabled=true]: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-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-[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-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'
const error = 'type-component-2xs text-danger-muted'
function validateEmail(value: unknown) {
const entry = String(value ?? '')
if (entry.length === 0) {
return 'Enter an email address.'
}
return entry.includes('@') ? null : 'Enter a valid email address.'
}
</script>
Usage guidelines
- Set
idonField.Rootrather than on the input, becauseLabelpoints itsforat the field’s control ID and anidhere leaves it pointing at nothing. - Only one control may register per field, so two inputs need two fields; a
second one throws
duplicate_field_control.
Anatomy
Render it inside a Field.Root, or on its own.
| Name | Required | Description |
|---|---|---|
| Input | true | Renders a native <input>, joined to the surrounding Field. |
<script setup lang="ts">
import { Field, Input, Label } from '@maas/mirror/vue'
</script>
<template>
<Input v-model="query" type="search" />
<Field.Root :options="{ name: 'email' }">
<Label>Email</Label>
<Input v-model="email" type="email" />
</Field.Root>
</template>
Examples
Controlled and uncontrolled
defaultValue seeds the value and leaves Input owning it, while v-model
hands it to you, and which of the two applies is
decided once at mount.
<template>
<Input default-value="Robin" name="nickname" @update:model-value="track" />
<Input v-model="query" type="search" placeholder="Search" />
</template>
<script setup lang="ts">
const query = ref('')
</script>
Start a controlled input from '' rather than undefined, or it stays
uncontrolled for as long as it is mounted.
Inside a field
The input takes the field’s control ID, name and flags, and reports its value,
its focus and its blur back, which is what the field derives data-dirty,
data-touched and data-focused from.
<template>
<Field.Root
:options="{ name: 'email', required: true, validationMode: 'onBlur' }"
>
<Label>Email</Label>
<Input v-model="email" type="email" />
<Field.Description>We only use this to send receipts.</Field.Description>
<Field.Error match="valueMissing">Enter an email address.</Field.Error>
<Field.Error match="typeMismatch">That is not an email address.</Field.Error>
</Field.Root>
</template>
Because the element is a real <input>, the field reads its ValidityState
directly, which is what lets Field.Error match on valueMissing and its
siblings.
Each inherited flag resolves as the prop, then the field’s value, then false,
so leaving a prop off entirely is how you inherit it.
<template>
<Field.Root :options="{ name: 'slug', disabled: true }">
<Label>Slug</Label>
<Input v-model="slug" :disabled="false" />
</Field.Root>
</template>
Outside a field
Without a field, Input is a bare <input> with a generated ID and only the
states it can work out for itself, so data-valid, data-invalid, data-dirty
and data-touched are absent rather than guessed.
<template>
<Input v-model="query" type="search" required />
</template>
Styling from state
Every state is an attribute selector away, with no bound class in between.
<template>
<Input v-model="email" class="input" type="email" />
</template>
<style>
.input[data-focused='true'] {
outline: 2px solid var(--app-color-focus-outline);
}
.input[data-invalid='true'] {
border-color: var(--app-color-danger-border-subtle);
}
</style>
Focusing the input
Input renders a void element, so it exposes focus(), blur() and select()
on its component instance instead of a slot.
<template>
<Input ref="coupon" default-value="SUMMER" name="coupon" />
<Button @click="coupon?.select()">Replace code</Button>
</template>
<script setup lang="ts">
const coupon = useTemplateRef('coupon')
</script>
API reference
Standalone. Ordinary props, no store. Attributes not listed below
(placeholder, autocomplete, inputmode, min, max, pattern) fall
through to the element unchanged.
Input
Renders an <input>.
Props
Emits
Slot props
None. Input renders a void element and exposes focus(), blur() and
select() on its instance instead.
Data attributes
The field state set, with no additions and no
data-scope.
CSS variables
Errors
Accessibility
Native throughout: no roles, no key handlers, and inside a Field nothing but
aria-describedby, aria-invalid, aria-required and aria-readonly is
added. disabled renders the native attribute, so the input leaves the tab
order the way any disabled input does.