Skip to content

Input

A native input that joins a Field on its own, with the ID, the ARIA wiring and the states already right.

View source View as Markdown

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.

App.vue
<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 id on Field.Root rather than on the input, because Label points its for at the field’s control ID and an id here 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.

NameRequiredDescription
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

PropTypeDefault
id
stringthe field’s controlId, else generated
modelValue
string | numberundefined
defaultValue
string | number''
type
string'text'
name
stringundefined
disabled
booleanundefined
readOnly
booleanundefined
required
booleanundefined

Emits

EmitPayload
update:modelValue
string | number
focus
FocusEvent
blur
FocusEvent

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.

AttributeValue
data-disabled
true
data-readonly
true
data-required
true
data-valid
true
data-invalid
true
data-dirty
true
data-touched
true
data-filled
true
data-focused
true

CSS variables

VariableDefault
--mirror-input-disabled-cursor
not-allowed

Errors

Code
invalid_input_type
duplicate_field_control

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.