Live Vue drills test whether you can go from a blank file to a working, accessible component quickly and cleanly. Reviewers watch for typed props and emits, defineModel, composables for logic, correct reactivity (no lost reactivity when destructuring, cleanup on unmount), keyboard support, and whether the code stays readable at minute forty. Each drill below lists the specification a strong candidate states before typing, then the core of a solution.
v-model input, add on Enter, toggle, delete, filter (all/active/done) in the URL or local state, counts, key on items, persisted with a composable. Watch for: mutating props, index as key, filter inside the template without computed.role="combobox" with aria-expanded, aria-controls, aria-activedescendant, role="listbox"/option, live region for result count, highlight without XSS (no v-html on user input), click outside, loading and empty states.<Teleport to="body">, role="dialog" with aria-modal and aria-labelledby, focus moved in on open and restored on close, focus trapped (Tab cycles), Escape closes, background scroll locked, inert on the app root or a focus-trap library; or the native <dialog> element with showModal(), which gives most of this for free.role="tablist"/tab/tabpanel, aria-selected, roving tabindex (only the active tab is tabbable, arrows move), aria-controls/aria-labelledby pairs, v-model for the active tab, lazy panels with <KeepAlive> when panels are expensive.toSorted with localeCompare or numeric compare, stable), pagination (slice), aria-sort on headers, <th scope>, <caption>, empty and loading states, slots for cells, virtualisation when rows exceed a few hundred (@tanstack/vue-virtual or a fixed-height window).IntersectionObserver sentinel, cursor-based loading, no duplicate loads while pending, error with retry, "back to top", scroll restoration on return (useNuxtApp().$router scrollBehavior or saving offsets).zod/valibot) shared with the server, validate on blur and submit, errors associated via aria-describedby, aria-invalid, focus the first invalid field on submit, disabled submit while pending, optimistic reset.MaybeRefOrGetter and use toValue; return refs, not reactive objects that break on destructure; clean up in onScopeDispose; be SSR-safe (import.meta.client guards, onMounted for browser APIs); provide isSupported for browser APIs; test with withSetup/effectScope.useDebounceFn, useLocalStorage, useIntersectionObserver, useEventListener, useFocusTrap, onClickOutside); in interviews, name it and then implement the asked one anyway.An accessible autocomplete with a debounced, cancellable fetch:
<script setup lang="ts" generic="T extends { id: string, label: string }">
const props = defineProps<{ fetch: (q: string, signal: AbortSignal) => Promise<T[]>, label: string, minLength?: number }>()
const emit = defineEmits<{ select: [item: T] }>()
const model = defineModel<string>({ default: '' })
const items = shallowRef<T[]>([])
const open = ref(false)
const active = ref(-1)
const loading = ref(false)
const id = useId()
let controller: AbortController | undefined
const search = useDebounceFn(async (q: string) => {
controller?.abort()
if (q.length < (props.minLength ?? 1)) { items.value = []; open.value = false; return }
controller = new AbortController()
loading.value = true
try {
items.value = await props.fetch(q, controller.signal)
open.value = true
active.value = items.value.length ? 0 : -1
}
catch (err) {
if ((err as Error).name !== 'AbortError') throw err
}
finally {
if (!controller?.signal.aborted) loading.value = false
}
}, 250)
watch(model, q => search(q))
function select(item: T) {
model.value = item.label
open.value = false
emit('select', item)
}
function onKeydown(e: KeyboardEvent) {
if (!open.value) return
const last = items.value.length - 1
switch (e.key) {
case 'ArrowDown': e.preventDefault(); active.value = active.value >= last ? 0 : active.value + 1; break
case 'ArrowUp': e.preventDefault(); active.value = active.value <= 0 ? last : active.value - 1; break
case 'Home': e.preventDefault(); active.value = 0; break
case 'End': e.preventDefault(); active.value = last; break
case 'Enter': if (active.value >= 0) { e.preventDefault(); select(items.value[active.value]!) } break
case 'Escape': e.preventDefault(); open.value = false; break
}
}
const root = useTemplateRef('root')
onClickOutside(root, () => (open.value = false))
onScopeDispose(() => controller?.abort())
</script>
<template>
<div ref="root" class="relative">
<label :for="`${id}-input`">{{ label }}</label>
<input
:id="`${id}-input`"
v-model="model"
role="combobox"
autocomplete="off"
:aria-expanded="open"
:aria-controls="`${id}-listbox`"
:aria-activedescendant="open && active >= 0 ? `${id}-option-${active}` : undefined"
@keydown="onKeydown"
@focus="items.length && (open = true)"
>
<ul v-show="open" :id="`${id}-listbox`" role="listbox" class="absolute z-10 w-full">
<li
v-for="(item, i) in items"
:id="`${id}-option-${i}`"
:key="item.id"
role="option"
:aria-selected="i === active"
:class="{ 'bg-primary/10': i === active }"
@mousedown.prevent="select(item)"
@mousemove="active = i"
>
{{ item.label }}
</li>
<li v-if="!loading && !items.length" role="option" aria-disabled="true">No results</li>
</ul>
<p class="sr-only" role="status">{{ loading ? 'Loading' : `${items.length} results` }}</p>
</div>
</template>
A dialog on the native element, which supplies focus trapping, Escape and the top layer:
<script setup lang="ts">
const open = defineModel<boolean>('open', { default: false })
defineProps<{ title: string }>()
const dialog = useTemplateRef('dialog')
let opener: HTMLElement | null = null
watch(open, (value) => {
if (!dialog.value) return
if (value) { opener = document.activeElement as HTMLElement | null; dialog.value.showModal() }
else { dialog.value.close(); opener?.focus() }
}, { flush: 'post' })
// user pressed Escape or the form method="dialog" closed it: keep the model in sync
function onClose() { open.value = false }
</script>
<template>
<dialog ref="dialog" :aria-labelledby="`${$.uid}-title`" class="m-auto rounded-lg p-6 backdrop:bg-black/50" @close="onClose">
<h2 :id="`${$.uid}-title`">{{ title }}</h2>
<slot :close="() => (open = false)" />
<form method="dialog" class="mt-4 text-right"><button>Close</button></form>
</dialog>
</template>
Tabs with roving tabindex and v-model:
<script setup lang="ts">
const props = defineProps<{ tabs: { id: string, label: string }[] }>()
const active = defineModel<string>({ required: true })
const id = useId()
const index = computed(() => props.tabs.findIndex(t => t.id === active.value))
function onKeydown(e: KeyboardEvent) {
const n = props.tabs.length
const next = { ArrowRight: (index.value + 1) % n, ArrowLeft: (index.value - 1 + n) % n, Home: 0, End: n - 1 }[e.key]
if (next === undefined) return
e.preventDefault()
active.value = props.tabs[next]!.id
;(e.currentTarget as HTMLElement).querySelectorAll<HTMLElement>('[role=tab]')[next]?.focus()
}
</script>
<template>
<div role="tablist" @keydown="onKeydown">
<button
v-for="tab in tabs"
:id="`${id}-tab-${tab.id}`"
:key="tab.id"
role="tab"
:aria-selected="tab.id === active"
:aria-controls="`${id}-panel-${tab.id}`"
:tabindex="tab.id === active ? 0 : -1"
@click="active = tab.id"
>
{{ tab.label }}
</button>
</div>
<div v-for="tab in tabs" :id="`${id}-panel-${tab.id}`" :key="tab.id" role="tabpanel" :aria-labelledby="`${id}-tab-${tab.id}`" :hidden="tab.id !== active" tabindex="0">
<slot :name="tab.id" />
</div>
</template>
A sortable, paginated table core and an infinite list sentinel:
export function useTable<T>(rows: MaybeRefOrGetter<T[]>, { pageSize = 20 } = {}) {
const sortKey = ref<keyof T | null>(null)
const sortDir = ref<'asc' | 'desc'>('asc')
const page = ref(1)
const collator = new Intl.Collator(undefined, { numeric: true, sensitivity: 'base' })
const sorted = computed(() => {
const list = toValue(rows)
const key = sortKey.value
if (!key) return list
const dir = sortDir.value === 'asc' ? 1 : -1
return list.toSorted((a, b) => {
const x = a[key], y = b[key]
if (typeof x === 'number' && typeof y === 'number') return (x - y) * dir
return collator.compare(String(x ?? ''), String(y ?? '')) * dir // stable: toSorted preserves ties
})
})
const pageCount = computed(() => Math.max(1, Math.ceil(sorted.value.length / pageSize)))
const pageRows = computed(() => sorted.value.slice((page.value - 1) * pageSize, page.value * pageSize))
watch(sorted, () => { page.value = 1 })
function sortBy(key: keyof T) {
if (sortKey.value === key) sortDir.value = sortDir.value === 'asc' ? 'desc' : 'asc'
else { sortKey.value = key; sortDir.value = 'asc' }
}
const ariaSort = (key: keyof T) => sortKey.value !== key ? 'none' : sortDir.value === 'asc' ? 'ascending' : 'descending'
return { sortKey, sortDir, page, pageCount, pageRows, sortBy, ariaSort }
}
<script setup lang="ts" generic="T extends { id: string }">
const props = defineProps<{ load: (cursor: string | null) => Promise<{ items: T[], next: string | null }> }>()
const items = shallowRef<T[]>([])
const cursor = ref<string | null>(null)
const done = ref(false)
const pending = ref(false)
const error = ref<unknown>(null)
const sentinel = useTemplateRef('sentinel')
async function more() {
if (pending.value || done.value) return
pending.value = true; error.value = null
try {
const page = await props.load(cursor.value)
items.value = [...items.value, ...page.items]
cursor.value = page.next
done.value = page.next === null
}
catch (e) { error.value = e }
finally { pending.value = false }
}
useIntersectionObserver(sentinel, ([entry]) => { if (entry?.isIntersecting) more() }, { rootMargin: '400px' })
</script>
<template>
<ul><li v-for="item in items" :key="item.id"><slot :item="item" /></li></ul>
<p v-if="error" role="alert">Could not load. <button @click="more">Retry</button></p>
<div ref="sentinel" aria-hidden="true" />
<p v-if="pending" role="status" class="sr-only">Loading more</p>
</template>
Composables written the way reviewers expect:
export function useLocalStorage<T>(key: string, initial: T) {
const state = ref(initial) as Ref<T>
if (import.meta.client) {
onMounted(() => { // after hydration: server and first client render agree
try { const raw = localStorage.getItem(key); if (raw !== null) state.value = JSON.parse(raw) } catch {}
watch(state, v => localStorage.setItem(key, JSON.stringify(v)), { deep: true })
useEventListener(window, 'storage', (e) => { if (e.key === key && e.newValue !== null) state.value = JSON.parse(e.newValue) }) // other tabs
})
}
return state
}
export function useEventListener<K extends keyof WindowEventMap>(target: MaybeRefOrGetter<EventTarget | null | undefined>, event: K, handler: (e: WindowEventMap[K]) => void, options?: AddEventListenerOptions) {
if (import.meta.server) return
watch(() => toValue(target), (el, _, onCleanup) => {
if (!el) return
el.addEventListener(event, handler as EventListener, options)
onCleanup(() => el.removeEventListener(event, handler as EventListener, options))
}, { immediate: true, flush: 'post' })
}
return reactive({ page, rows }) then const { page } = useTable() gives a plain number. Return refs (or use toRefs) so consumers can destructure, and accept MaybeRefOrGetter inputs read through toValue inside computed.
Wrapping the matched substring in <mark> via v-html on user-entered or API text is an XSS hole. Split the label into parts and render them with <template v-for> and a <mark> element instead.
fetch; then run axe on it and test arrows, Enter and Escape with Vitest and @vue/test-utils.useTable with tests for stable sorting of ties and page reset on re-sort."Debounce the input and cancel stale requests with an AbortController so a fast typist never sees results for an earlier query. Keyboard first: arrows move the active option, Enter selects, Escape closes, Home and End jump. Accessibility with the combobox pattern: the input has role=combobox, aria-expanded, aria-controls pointing at the listbox and aria-activedescendant for the active option, options have role=option and aria-selected, and a live region announces the result count. Loading, empty and error states, click-outside to close, abort on unmount, and no v-html for highlighting. I would type it generically so it works for any item shape, and keep the fetch as an injected function so it is testable without a network."
Nothing yet. Notes filed here show up on this page and the section overview.
Async and data processing
[object Object]
Nuxt drills
Fullstack Nuxt exercises in the shape interviewers use — a validated API route plus a page that consumes it, a protected area with session middleware, an error page, a dynamic SEO route, cached list endpoints with route rules, a plugin that provides a helper, shared state across components, and a mini layer — each with the specification to state, the solution core, and what is checked.