Async states
Every screen that fetches something has four renders, not one: the wait, the failure, the empty answer, and the rows. Skeleton stands in for the wait, Alert carries the failure beside the retry, and EmptyState says that nothing arrived rather than leaving a blank.
import { Alert, Button, Card, EmptyState, Skeleton, Toolbar } from "ropav";async.vue
<script setup lang="ts">
import {
Alert,
AlertContent,
AlertDescription,
AlertIndicator,
AlertTitle,
Button,
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
EmptyState,
Skeleton,
Toolbar,
} from "ropav";
import { onBeforeUnmount, shallowRef } from "vue";
type Outcome = "empty" | "error" | "ready";
interface Project {
id: string;
name: string;
owner: string;
summary: string;
}
const ALL: Project[] = [
{ id: "atlas", name: "Atlas", owner: "Ada", summary: "Tiles the coastline survey." },
{ id: "beacon", name: "Beacon", owner: "Grace", summary: "Watches the ingest queue." },
{ id: "cinder", name: "Cinder", owner: "Alan", summary: "Rebuilds the search index nightly." },
];
const projects = shallowRef<Project[]>([]);
const error = shallowRef<null | string>(null);
const isLoading = shallowRef(true);
let timer: ReturnType<typeof setTimeout> | undefined;
onBeforeUnmount(() => clearTimeout(timer));
/* Stands in for the request. Only the three states it settles into are worth copying. */
const load = (outcome: Outcome) => {
clearTimeout(timer);
isLoading.value = true;
error.value = null;
timer = setTimeout(() => {
isLoading.value = false;
projects.value = outcome === "ready" ? ALL : [];
error.value = outcome === "error" ? "The project service did not answer." : null;
}, 1200);
};
load("ready");
</script>
<template>
<div class="screen">
<Toolbar aria-label="Load the projects">
<Button :is-pending="isLoading" @click="load('ready')">Reload</Button>
<Button :is-pending="isLoading" variant="secondary" @click="load('empty')">
Return nothing
</Button>
<Button :is-pending="isLoading" variant="secondary" @click="load('error')">Fail</Button>
</Toolbar>
<!-- One of four, never two: loading wins, then the error, then the empty collection. -->
<div v-if="isLoading" class="list">
<Card v-for="placeholder in 3" :key="placeholder" class="card">
<Skeleton class="line line--title" />
<Skeleton class="line" />
</Card>
</div>
<Alert v-else-if="error" status="danger">
<AlertIndicator />
<AlertContent>
<AlertTitle>Could not load projects</AlertTitle>
<AlertDescription>{{ error }}</AlertDescription>
</AlertContent>
<Button size="sm" @click="load('ready')">Try again</Button>
</Alert>
<EmptyState v-else-if="projects.length === 0">
No projects yet. The first one you create lands here.
</EmptyState>
<div v-else class="list">
<Card v-for="project in projects" :key="project.id" class="card">
<CardHeader>
<CardTitle>{{ project.name }}</CardTitle>
<CardDescription>Kept by {{ project.owner }}</CardDescription>
</CardHeader>
<CardContent>{{ project.summary }}</CardContent>
</Card>
</div>
</div>
</template>
<style scoped>
.screen {
display: flex;
max-width: var(--rp-container-md);
flex-direction: column;
gap: calc(var(--rp-spacing) * 4);
}
.list {
display: flex;
flex-direction: column;
gap: calc(var(--rp-spacing) * 3);
}
.card {
display: flex;
flex-direction: column;
gap: calc(var(--rp-spacing) * 2);
}
/* Sized to the text it stands in for, so the list does not resize when the rows arrive. */
.line {
height: calc(var(--rp-spacing) * 3);
width: 70%;
border-radius: var(--rp-radius);
}
.line.line--title {
width: 35%;
}
</style>One of the four, never two
The states are ordered, not independent: loading wins, then the failure, then the empty collection, and the rows are what is left. Written as separate conditions they overlap — an empty array is empty while the first request is still in flight, so a skeleton and an empty state render together and the screen says two things at once. A v-if chain is what keeps that from happening.
An error is not a toast
A failed load belongs in the page, next to the control that retries it, because the reader has to be able to act on it after reading it. A toast is for something that already happened and needs no answer — a save that worked. Queue one for that and leave the failure where it can be read twice.
Pending keeps the button reachable
is-pending blocks the button being activated again but leaves it focusable, so a screen reader can still land on the control and hear what it is. is-disabled takes it out of the tab order instead, which moves focus somewhere unannounced at the moment the reader is waiting to be told what changed.
Accessibility
- A skeleton is decoration. It carries no text, so a reader is told nothing while it shows — which is why the retry and the empty state below carry real sentences rather than an icon.
- The skeleton rows are sized to the text they stand in for, so nothing jumps when the answer arrives and the reader does not lose their place.
Toolbaris one stop in the tab order with the arrow keys moving inside it, so a row of load controls does not cost three tabs to get past.