Forms That Are Pleasant

Autofocus, submit on Enter, one submit per click, data kept after a failure, and errors a screen reader announces.

Syntax<form @submit.prevent="submit">

Correct validation is not the same as a usable form. Most of what people dislike about forms is a small set of fixable details.

Focus and Enter

Focus the first field when a form appears. The autofocus attribute is honoured when the document loads, not when a component mounts after a client-side navigation, so call focus() in onMounted on a template ref. Use a real <form> with a type="submit" button: that is what makes Enter submit, and screen readers announce the element as a form. Handle @submit.prevent, never a click on the button.

One submit per click

A slow request invites a second click, and the server receives two orders. Keep a pending ref, return early while it is true, and disable the button. Disabling alone is not enough, because the first click can fire before a re-render.

Keep what they typed

On failure, leave the values alone. Reset only after a success. A form that empties itself because the network blinked is the fastest way to lose a user.

Say what went wrong, audibly

A sighted user sees red text appear; a screen reader user hears nothing unless you ask for it. Put the message in an element with role="alert", which is announced when it is inserted, and move focus to the first invalid field. Mark that field with aria-invalid="true" and point aria-describedby at the message so the two are connected.

Example

Example · vue
<!-- src/components/ProfileForm.vue -->
<script setup lang="ts">
import { onMounted, ref, useTemplateRef } from 'vue'

const props = defineProps<{
  save: (name: string) => Promise<void>
}>()

const name = ref('')
const error = ref('')
const pending = ref(false)
const nameInput = useTemplateRef<HTMLInputElement>('nameInput')

onMounted(() => nameInput.value?.focus())

async function submit() {
  if (pending.value) return
  if (name.value.trim() === '') {
    error.value = 'Enter your name'
    nameInput.value?.focus()
    return
  }

  pending.value = true
  try {
    await props.save(name.value.trim())
    error.value = ''
  } catch {
    error.value = 'Saving failed. Your details are still here, try again.'
  } finally {
    pending.value = false
  }
}
</script>

<template>
  <form @submit.prevent="submit">
    <p v-if="error" id="name-error" role="alert">{{ error }}</p>

    <label for="name">Name</label>
    <input
      id="name"
      ref="nameInput"
      v-model="name"
      :aria-invalid="error ? 'true' : undefined"
      :aria-describedby="error ? 'name-error' : undefined"
    />

    <button type="submit" :disabled="pending">{{ pending ? 'Saving' : 'Save' }}</button>
    <button type="button" :disabled="pending" @click="name = ''">Clear</button>
  </form>
</template>

<style scoped>
[role='alert'] {
  color: #b00020;
}
</style>

When to use it

  • Focus the first field when a dialog form opens after a route change.
  • Stop a slow connection turning one click into two charges.
  • Tell a screen reader user that a submit failed and why.
  • Keep a long answer in a textarea after the server returns a 500.

More examples

An error summary that moves focus

For a long form, one message per field is not enough, because the first problem may be off screen. A summary at the top lists every message as a link to its field's id. `tabindex="-1"` makes the box focusable by script but not by Tab, and `nextTick` waits for the element to exist before focusing it. The parent calls `focusSummary()` after a failed submit.

Example · vue
<!-- src/components/ErrorSummary.vue -->
<script setup lang="ts">
import { nextTick, useTemplateRef } from 'vue'

const props = defineProps<{
  errors: { fieldId: string; message: string }[]
}>()

const box = useTemplateRef<HTMLDivElement>('box')

async function focusSummary() {
  await nextTick()
  box.value?.focus()
}

defineExpose({ focusSummary })
</script>

<template>
  <div v-if="props.errors.length > 0" ref="box" tabindex="-1" role="alert">
    <h2>{{ props.errors.length }} problems to fix</h2>
    <ul>
      <li v-for="item in props.errors" :key="item.fieldId">
        <a :href="`#${item.fieldId}`">{{ item.message }}</a>
      </li>
    </ul>
  </div>
</template>

<style scoped>
div {
  border: 1px solid #b00020;
  padding: 0.75rem;
}
</style>

Keeping a draft across a reload

Anything a user types by hand for more than a minute deserves a draft. A `watch` on the value writes to `sessionStorage`, and the initial value reads it back, so a reload or an accidental back navigation costs nothing. The draft is cleared only on a successful send. `role="status"` announces the confirmation politely, without interrupting.

Example · vue
<!-- src/components/FeedbackForm.vue -->
<script setup lang="ts">
import { ref, watch } from 'vue'

const storageKey = 'feedback-draft'
const message = ref(sessionStorage.getItem(storageKey) ?? '')
const sent = ref(false)

watch(message, (value) => {
  sessionStorage.setItem(storageKey, value)
})

function submit() {
  if (message.value.trim() === '') return
  sent.value = true
  message.value = ''
  sessionStorage.removeItem(storageKey)
}
</script>

<template>
  <form @submit.prevent="submit">
    <label for="message">Feedback</label>
    <textarea id="message" v-model="message" rows="5"></textarea>
    <button type="submit" :disabled="message.trim() === ''">Send</button>
    <p v-if="sent" role="status">Thank you, that was sent.</p>
  </form>
</template>

<style scoped>
textarea {
  width: 100%;
}
</style>

Enter in a textarea, Enter in a field

Enter submits a form from a single-line input, but inside a textarea it has to insert a newline, so the shortcut is Enter with a modifier. The key and system modifiers compose: `@keydown.enter.meta` fires only when both are held. The visible button stays, because a keyboard shortcut nobody is told about is not a feature. Indexing the posted list by index is safe here only because entries are append-only and not editable.

Example · vue
<!-- src/components/CommentBox.vue -->
<script setup lang="ts">
import { ref } from 'vue'

const comment = ref('')
const posted = ref<string[]>([])

function post() {
  const text = comment.value.trim()
  if (text === '') return
  posted.value = [...posted.value, text]
  comment.value = ''
}
</script>

<template>
  <form @submit.prevent="post">
    <label for="comment">Comment</label>
    <textarea
      id="comment"
      v-model="comment"
      rows="3"
      @keydown.enter.meta="post"
      @keydown.enter.ctrl="post"
    ></textarea>
    <p>Enter adds a line. Ctrl or Command with Enter posts.</p>
    <button type="submit">Post</button>
    <ul>
      <li v-for="(item, index) in posted" :key="index">{{ item }}</li>
    </ul>
  </form>
</template>

<style scoped>
textarea {
  width: 100%;
}
</style>

Discussion

  • Be the first to comment on this lesson.