Forms That Are Pleasant
Autofocus, submit on Enter, one submit per click, data kept after a failure, and errors a screen reader announces.
<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
<!-- 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.
<!-- 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.
<!-- 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.
<!-- 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