Async task & feedback patterns
Every async state has one correct component — treat this page as rules, not
suggestions. Structured content that is on its way gets
md-skeleton; queued work with no measurable progress
gets md-loading-indicator; a running job
you can measure gets a determinate
md-progress-indicator; state itself is a
labelled md-status-dot; destructive cancellation
is a blocking md-dialog; and everything that already
happened is a snackbar — success plain, error with a retry action, reversible
actions with Undo. The demo below runs a whole job queue through those rules.
"Usage rollup - July" is still running. Cancelling discards the partial file; a restarted job begins again from step 1.
Show code for each technology
<!-- index.html <head> — the icon font the components draw from -->
<link rel="stylesheet"
href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:opsz,wght,FILL,GRAD@20..48,100..700,0..1,-50..200&display=swap">
<!-- index.html — register the AWC UI elements once -->
<script type="module">
import '@awc-ui/core/define';
</script>
<!-- Initial load: ONE live region, every skeleton inside it silent -->
<div role="status" aria-label="Loading export jobs">
<md-skeleton variant="circular" width="12px" height="12px" announce="false"></md-skeleton>
<md-skeleton variant="text" lines="2" full-width announce="false"></md-skeleton>
</div>
<md-list label="Export jobs" interaction-mode="multi-action">
<!-- RUNNING: live labelled dot + determinate progress + cancel affordance -->
<md-list-item headline="Usage rollup - July">
<span slot="leading" style="position: relative; display: inline-flex; width: 12px; height: 12px;">
<md-status-dot state="online" live label="Running"></md-status-dot>
</span>
<span slot="supporting-text">
<span>Step 3 of 5 - aggregating events (38%)</span>
<md-progress-indicator value="38" max="100" label="Usage rollup progress"></md-progress-indicator>
</span>
<md-icon-button slot="trailing" icon="close" size="xs"
aria-label="Cancel the usage rollup export"></md-icon-button>
</md-list-item>
<!-- QUEUED: indeterminate wait, no fake percentages -->
<md-list-item headline="Invoice PDF batch (412 files)"
supporting-text="Queued behind 1 job - no worker assigned yet">
<span slot="leading" style="position: relative; display: inline-flex; width: 12px; height: 12px;">
<md-status-dot state="neutral" label="Queued"></md-status-dot>
</span>
<md-loading-indicator slot="trailing" label="Waiting for a worker"
style="--md-loading-indicator-size: 28px;"></md-loading-indicator>
</md-list-item>
<!-- FAILED: state as words + an inline retry affordance -->
<md-list-item headline="Webhook redelivery - order.created"
supporting-text="Attempt 2 failed at 09:12 - endpoint returned 503">
<span slot="leading" style="position: relative; display: inline-flex; width: 12px; height: 12px;">
<md-status-dot state="busy" label="Failed"></md-status-dot>
</span>
<md-icon-button slot="trailing" icon="refresh" variant="tonal" size="xs"
aria-label="Retry webhook redelivery"></md-icon-button>
</md-list-item>
</md-list>
<!-- ONE snackbar element serves all three conventions -->
<md-snackbar></md-snackbar>
<!-- Destructive cancellation blocks the flow and cannot be click-away dismissed -->
<md-dialog headline="Cancel this export?" icon="warning" scrim-dismissible="false">
<p>Cancelling discards the partial file.</p>
<md-button slot="actions" variant="text">Keep running</md-button>
<md-button slot="actions" variant="filled">Cancel export</md-button>
</md-dialog>
<script type="module">
const bar = document.querySelector('[data-bar]');
const dialog = document.querySelector('[data-cancel-dialog]');
const loading = document.querySelector('[data-loading]');
const jobs = document.querySelector('[data-jobs]');
const prog = document.querySelector('[data-progress]');
const cancelBtn = document.querySelector('[data-cancel]');
const retryBtn = document.querySelector('[data-retry]');
const queueSpinner = document.querySelector('[data-queue-spinner]');
const dot = function (id) { return document.querySelector('[data-dot="' + id + '"]'); };
const status = function (id) { return document.querySelector('[data-status="' + id + '"]'); };
// Snackbar conventions: one element, one pending-close callback.
// RULE: the action button never dismisses by itself -> hide('action').
// RULE: commit/undo on mdClose by reason, never inside mdAction.
let onClose = null;
bar.addEventListener('mdAction', function () { bar.hide('action'); });
bar.addEventListener('mdClose', function (e) {
const cb = onClose; onClose = null;
if (cb) cb(e.detail.reason);
});
function toast(message, opts) {
opts = opts || {};
bar.message = message;
bar.action = opts.action || '';
bar.politeness = opts.assertive ? 'assertive' : 'polite';
bar.autoHide = !opts.sticky;
bar.closeable = !!opts.sticky;
bar.autoHideDuration = opts.duration || 4000;
onClose = opts.onClose || null;
bar.show();
}
// RULE: skeletons render until replaced - swap them for the real rows,
// never leave them up on error.
setTimeout(function () {
loading.style.display = 'none';
jobs.style.display = '';
startRollup();
}, 1400);
// Determinate progress: raw value against max, text mirror beside the bar.
let value = 38;
let timer = null;
function startRollup() {
timer = setInterval(function () {
value += 2;
if (value >= 100) {
value = 100;
clearInterval(timer);
timer = null;
}
prog.value = value;
status('rollup').textContent = 'Step 3 of 5 - aggregating events (' + value + '%)';
if (value === 100) finishRollup();
}, 250);
}
function finishRollup() {
// RULE: mdComplete follows the complete flag, not value === max.
prog.complete = true;
const d = dot('rollup');
d.live = false;
d.label = 'Finished';
status('rollup').textContent = 'Completed - 2.1 MB written to exports/usage-2026-07.csv';
cancelBtn.style.display = 'none';
toast('Usage rollup finished');
// The queue drains: the queued job takes the freed worker.
queueSpinner.remove();
const inv = dot('invoices');
inv.state = 'online';
inv.label = 'Running';
inv.live = true;
status('invoices').textContent = 'Rendering invoice 18 of 412...';
}
// Cancellation: destructive -> dialog, then a reversible Undo snackbar.
cancelBtn.addEventListener('click', function () {
if (timer) dialog.open = true;
});
document.querySelector('[data-keep]').addEventListener('click', function () {
dialog.open = false;
});
document.querySelector('[data-confirm]').addEventListener('click', function () {
dialog.open = false;
clearInterval(timer);
timer = null;
const at = value;
prog.style.display = 'none';
const d = dot('rollup');
d.state = 'offline';
d.live = false;
d.label = 'Cancelled';
status('rollup').textContent = 'Cancelled at ' + at + '% - partial output discarded';
cancelBtn.style.display = 'none';
toast('Usage rollup cancelled', {
action: 'Undo',
duration: 6000,
onClose: function (reason) {
if (reason !== 'action') return; // auto/close = committed
prog.style.display = '';
d.state = 'online';
d.live = true;
d.label = 'Running';
cancelBtn.style.display = '';
startRollup();
}
});
});
// Retry: first attempt fails again (sticky error snackbar with a Retry
// action), the next one lands.
let attempt = 2;
function runRetry() {
attempt += 1;
retryBtn.disabled = true;
const d = dot('webhooks');
d.state = 'online';
d.live = true;
d.label = 'Retrying';
status('webhooks').textContent = 'Attempt ' + attempt + ' - delivering to hooks.acme-billing.com...';
setTimeout(function () {
if (attempt < 4) {
d.state = 'busy';
d.live = false;
d.label = 'Failed';
status('webhooks').textContent = 'Attempt ' + attempt + ' failed - endpoint returned 503';
retryBtn.disabled = false;
toast('Webhook redelivery failed (503)', {
assertive: true,
sticky: true,
action: 'Retry',
onClose: function (reason) { if (reason === 'action') runRetry(); }
});
} else {
d.state = 'online';
d.live = false;
d.label = 'Delivered';
status('webhooks').textContent = 'Delivered on attempt ' + attempt + ' - 200 OK in 340 ms';
retryBtn.style.display = 'none';
toast('Webhook delivered');
}
}, 1600);
}
retryBtn.addEventListener('click', function () { runRetry(); });
</script>import { useEffect, useRef, useState } from 'react';
import {
MdButton, MdDialog, MdIconButton, MdList, MdListItem, MdLoadingIndicator,
MdProgressIndicator, MdSkeleton, MdSnackbar, MdStatusDot,
} from '@awc-ui/react';
const ROLLUP_DOT = { running: ['online', 'Running'], done: ['online', 'Finished'], cancelled: ['offline', 'Cancelled'] };
const HOOK_DOT = { failed: ['busy', 'Failed'], retrying: ['online', 'Retrying'], delivered: ['online', 'Delivered'] };
export default function ExportJobs() {
const bar = useRef(null);
const pendingClose = useRef(null);
const timer = useRef(null);
const [loading, setLoading] = useState(true);
const [rollup, setRollup] = useState({ value: 38, state: 'running' });
const [invoices, setInvoices] = useState('queued');
const [hook, setHook] = useState({ attempt: 2, state: 'failed' });
const [confirming, setConfirming] = useState(false);
// Snackbar conventions: ONE element, one pending-close callback.
// RULE: the action button never dismisses by itself -> hide('action').
// RULE: commit/undo on mdClose by reason, never inside mdAction.
const toast = (message, opts = {}) => {
const el = bar.current;
el.message = message; el.action = opts.action || '';
el.politeness = opts.assertive ? 'assertive' : 'polite';
el.autoHide = !opts.sticky; el.closeable = !!opts.sticky;
el.autoHideDuration = opts.duration || 4000;
pendingClose.current = opts.onClose || null;
el.show();
};
const startRollup = () => {
timer.current = setInterval(() => setRollup((r) => {
const value = Math.min(100, r.value + 2);
if (value === 100) { clearInterval(timer.current); timer.current = null; }
return { value, state: value === 100 ? 'done' : 'running' };
}), 250);
};
useEffect(() => { // stands in for the jobs fetch; skeletons render until replaced
const t = setTimeout(() => { setLoading(false); startRollup(); }, 1400);
return () => { clearTimeout(t); clearInterval(timer.current); };
}, []);
useEffect(() => { // the queue drains: the queued job takes the freed worker
if (rollup.state !== 'done') return;
setInvoices('running');
toast('Usage rollup finished');
}, [rollup.state]);
const confirmCancel = () => {
setConfirming(false);
clearInterval(timer.current); timer.current = null;
setRollup((r) => ({ ...r, state: 'cancelled' }));
toast('Usage rollup cancelled', { action: 'Undo', duration: 6000, onClose: (reason) => {
if (reason !== 'action') return; // auto/close = committed
setRollup((r) => ({ ...r, state: 'running' })); startRollup();
} });
};
const runRetry = (prev) => {
const attempt = prev + 1;
setHook({ attempt, state: 'retrying' });
setTimeout(() => { // stands in for the redelivery call
if (attempt < 4) {
setHook({ attempt, state: 'failed' });
toast('Webhook redelivery failed (503)', { assertive: true, sticky: true, action: 'Retry',
onClose: (reason) => { if (reason === 'action') runRetry(attempt); } });
} else { setHook({ attempt, state: 'delivered' }); toast('Webhook delivered'); }
}, 1600);
};
const rollupText = rollup.state === 'running' ? 'Step 3 of 5 - aggregating events (' + rollup.value + '%)'
: rollup.state === 'done' ? 'Completed - 2.1 MB written to exports/usage-2026-07.csv'
: 'Cancelled at ' + rollup.value + '% - partial output discarded';
const hookText = hook.state === 'retrying' ? 'Attempt ' + hook.attempt + ' - delivering to hooks.acme-billing.com...'
: hook.state === 'failed' ? 'Attempt ' + hook.attempt + ' failed - endpoint returned 503'
: 'Delivered on attempt ' + hook.attempt + ' - 200 OK in 340 ms';
if (loading) return ( // ONE live region, every skeleton inside it silent
<div role="status" aria-label="Loading export jobs" style={{ display: 'grid', gap: 18 }}>
<div style={{ display: 'flex', gap: 16, alignItems: 'center' }}>
<MdSkeleton variant="circular" width="12px" height="12px" announce={false} />
<div style={{ flex: 1 }}><MdSkeleton variant="text" lines={2} fullWidth announce={false} /></div>
</div>
</div>
);
return (
<>
<MdList label="Export jobs" interactionMode="multi-action">
<MdListItem headline="Usage rollup - July">
<span slot="leading"><MdStatusDot state={ROLLUP_DOT[rollup.state][0]} live={rollup.state === 'running'}
label={ROLLUP_DOT[rollup.state][1]} /></span>
<span slot="supporting-text">
<span>{rollupText}</span>
{rollup.state !== 'cancelled' &&
<MdProgressIndicator value={rollup.value} max={100} complete={rollup.state === 'done'}
label="Usage rollup progress" />}
</span>
{rollup.state === 'running' &&
<MdIconButton slot="trailing" icon="close" size="xs" aria-label="Cancel the usage rollup export"
onMdClick={() => setConfirming(true)} />}
</MdListItem>
<MdListItem headline="Invoice PDF batch (412 files)">
<span slot="leading"><MdStatusDot state={invoices === 'queued' ? 'neutral' : 'online'}
live={invoices === 'running'} label={invoices === 'queued' ? 'Queued' : 'Running'} /></span>
<span slot="supporting-text">{invoices === 'queued' ? 'Queued behind 1 job - no worker assigned yet' : 'Rendering invoice 18 of 412...'}</span>
{invoices === 'queued' && <MdLoadingIndicator slot="trailing" label="Waiting for a worker" />}
</MdListItem>
<MdListItem headline="Webhook redelivery - order.created">
<span slot="leading"><MdStatusDot state={HOOK_DOT[hook.state][0]} live={hook.state === 'retrying'}
label={HOOK_DOT[hook.state][1]} /></span>
<span slot="supporting-text">{hookText}</span>
{hook.state !== 'delivered' &&
<MdIconButton slot="trailing" icon="refresh" variant="tonal" size="xs" disabled={hook.state === 'retrying'}
aria-label="Retry webhook redelivery" onMdClick={() => runRetry(hook.attempt)} />}
</MdListItem>
</MdList>
<MdSnackbar ref={bar} onMdAction={() => bar.current.hide('action')}
onMdClose={(e) => { const cb = pendingClose.current; pendingClose.current = null; if (cb) cb(e.detail.reason); }} />
<MdDialog open={confirming} headline="Cancel this export?" icon="warning" scrimDismissible={false}
onMdCancel={() => setConfirming(false)}>
<p>"Usage rollup - July" is still running. Cancelling discards the partial file.</p>
<MdButton slot="actions" variant="text" onMdClick={() => setConfirming(false)}>Keep running</MdButton>
<MdButton slot="actions" variant="filled" onMdClick={confirmCancel}>Cancel export</MdButton>
</MdDialog>
</>
);
}// export-jobs.component.ts — standalone; the md-* tags are used directly
// (elements registered once in main.ts: import '@awc-ui/core/define';)
import { Component, CUSTOM_ELEMENTS_SCHEMA, ElementRef, ViewChild, computed, signal } from '@angular/core';
@Component({
selector: 'app-export-jobs',
standalone: true,
// Without this the compiler rejects the property bindings on md-* tags.
schemas: [CUSTOM_ELEMENTS_SCHEMA],
templateUrl: './export-jobs.component.html',
})
export class ExportJobsComponent {
@ViewChild('bar') bar!: ElementRef;
loading = signal(true);
rollup = signal({ value: 38, state: 'running' as 'running' | 'done' | 'cancelled' });
invoices = signal<'queued' | 'running'>('queued');
hook = signal({ attempt: 2, state: 'failed' as 'failed' | 'retrying' | 'delivered' });
confirming = signal(false);
rollupText = computed(() => {
const r = this.rollup();
if (r.state === 'running') return 'Step 3 of 5 - aggregating events (' + r.value + '%)';
if (r.state === 'done') return 'Completed - 2.1 MB written to exports/usage-2026-07.csv';
return 'Cancelled at ' + r.value + '% - partial output discarded';
});
hookText = computed(() => {
const h = this.hook();
if (h.state === 'retrying') return 'Attempt ' + h.attempt + ' - delivering to hooks.acme-billing.com...';
if (h.state === 'failed') return 'Attempt ' + h.attempt + ' failed - endpoint returned 503';
return 'Delivered on attempt ' + h.attempt + ' - 200 OK in 340 ms';
});
private timer: ReturnType<typeof setInterval> | null = null;
private pendingClose: ((reason: string) => void) | null = null;
ngOnInit() { // stands in for the jobs fetch; skeletons render until replaced
setTimeout(() => { this.loading.set(false); this.startRollup(); }, 1400);
}
// Snackbar conventions: ONE element, one pending-close callback.
// RULE: the action button never dismisses by itself -> hide('action').
// RULE: commit/undo on mdClose by reason, never inside mdAction.
toast(message: string, opts: any = {}) {
const el = this.bar.nativeElement;
el.message = message; el.action = opts.action || '';
el.politeness = opts.assertive ? 'assertive' : 'polite';
el.autoHide = !opts.sticky; el.closeable = !!opts.sticky;
el.autoHideDuration = opts.duration || 4000;
this.pendingClose = opts.onClose || null;
el.show();
}
onBarClose(e: Event) {
const cb = this.pendingClose; this.pendingClose = null;
if (cb) cb((e as CustomEvent<{ reason: string }>).detail.reason);
}
startRollup() {
this.timer = setInterval(() => {
const value = Math.min(100, this.rollup().value + 2);
if (value === 100) {
clearInterval(this.timer!); this.timer = null;
this.rollup.set({ value, state: 'done' });
this.invoices.set('running'); // the queue drains: the queued job takes the freed worker
this.toast('Usage rollup finished');
} else this.rollup.set({ value, state: 'running' });
}, 250);
}
confirmCancel() {
this.confirming.set(false);
if (this.timer) { clearInterval(this.timer); this.timer = null; }
this.rollup.update((r) => ({ ...r, state: 'cancelled' as const }));
this.toast('Usage rollup cancelled', { action: 'Undo', duration: 6000,
onClose: (reason: string) => {
if (reason !== 'action') return; // auto/close = committed
this.rollup.update((r) => ({ ...r, state: 'running' as const }));
this.startRollup();
} });
}
runRetry(prev: number) {
const attempt = prev + 1;
this.hook.set({ attempt, state: 'retrying' });
setTimeout(() => { // stands in for the redelivery call
if (attempt < 4) {
this.hook.set({ attempt, state: 'failed' });
this.toast('Webhook redelivery failed (503)', { assertive: true, sticky: true, action: 'Retry',
onClose: (reason: string) => { if (reason === 'action') this.runRetry(attempt); } });
} else { this.hook.set({ attempt, state: 'delivered' }); this.toast('Webhook delivered'); }
}, 1600);
}
}
<!-- export-jobs.component.html -->
@if (loading()) {
<!-- ONE live region, every skeleton inside it silent -->
<div role="status" aria-label="Loading export jobs">
<md-skeleton variant="circular" width="12px" height="12px" [announce]="false"></md-skeleton>
<md-skeleton variant="text" lines="2" full-width [announce]="false"></md-skeleton>
</div>
} @else {
<md-list label="Export jobs" interaction-mode="multi-action">
<md-list-item headline="Usage rollup - July">
<span slot="leading">
<md-status-dot [state]="rollup().state === 'cancelled' ? 'offline' : 'online'" [live]="rollup().state === 'running'"
[label]="rollup().state === 'running' ? 'Running' : rollup().state === 'done' ? 'Finished' : 'Cancelled'"></md-status-dot>
</span>
<span slot="supporting-text">
<span>{{ rollupText() }}</span>
@if (rollup().state !== 'cancelled') {
<md-progress-indicator [value]="rollup().value" max="100" [complete]="rollup().state === 'done'"
label="Usage rollup progress"></md-progress-indicator>
}
</span>
@if (rollup().state === 'running') {
<md-icon-button slot="trailing" icon="close" size="xs" (mdClick)="confirming.set(true)"
aria-label="Cancel the usage rollup export"></md-icon-button>
}
</md-list-item>
<md-list-item headline="Invoice PDF batch (412 files)">
<span slot="leading">
<md-status-dot [state]="invoices() === 'queued' ? 'neutral' : 'online'" [live]="invoices() === 'running'"
[label]="invoices() === 'queued' ? 'Queued' : 'Running'"></md-status-dot>
</span>
<span slot="supporting-text">{{ invoices() === 'queued' ? 'Queued behind 1 job - no worker assigned yet' : 'Rendering invoice 18 of 412...' }}</span>
@if (invoices() === 'queued') {
<md-loading-indicator slot="trailing" label="Waiting for a worker"></md-loading-indicator>
}
</md-list-item>
<md-list-item headline="Webhook redelivery - order.created">
<span slot="leading">
<md-status-dot [state]="hook().state === 'failed' ? 'busy' : 'online'" [live]="hook().state === 'retrying'"
[label]="hook().state === 'failed' ? 'Failed' : hook().state === 'retrying' ? 'Retrying' : 'Delivered'"></md-status-dot>
</span>
<span slot="supporting-text">{{ hookText() }}</span>
@if (hook().state !== 'delivered') {
<md-icon-button slot="trailing" icon="refresh" variant="tonal" size="xs" [disabled]="hook().state === 'retrying'"
(mdClick)="runRetry(hook().attempt)" aria-label="Retry webhook redelivery"></md-icon-button>
}
</md-list-item>
</md-list>
}
<md-snackbar #bar (mdAction)="bar.nativeElement.hide('action')" (mdClose)="onBarClose($event)"></md-snackbar>
<md-dialog [open]="confirming()" headline="Cancel this export?" icon="warning" [scrimDismissible]="false"
(mdCancel)="confirming.set(false)">
<p>"Usage rollup - July" is still running. Cancelling discards the partial file.</p>
<md-button slot="actions" variant="text" (mdClick)="confirming.set(false)">Keep running</md-button>
<md-button slot="actions" variant="filled" (mdClick)="confirmCancel()">Cancel export</md-button>
</md-dialog><script setup lang="ts">
import { computed, nextTick, onMounted, reactive, ref } from 'vue';
import {
MdButton, MdDialog, MdIconButton, MdList, MdListItem, MdLoadingIndicator,
MdProgressIndicator, MdSkeleton, MdSnackbar, MdStatusDot,
} from '@awc-ui/vue';
const loading = ref(true);
const rollup = reactive({ value: 38, state: 'running' });
const invoices = ref('queued');
const hook = reactive({ attempt: 2, state: 'failed' });
const confirming = ref(false);
const bar = ref();
const snack = reactive({ message: '', action: '', politeness: 'polite', autoHide: true, closeable: false, duration: 4000 });
let pendingClose: ((reason: string) => void) | null = null;
let timer: ReturnType<typeof setInterval> | null = null;
// Snackbar conventions: ONE element, one pending-close callback.
// RULE: the action button never dismisses by itself -> hide('action').
// RULE: commit/undo on mdClose by reason, never inside mdAction.
function toast(message: string, opts: any = {}) {
snack.message = message; snack.action = opts.action || '';
snack.politeness = opts.assertive ? 'assertive' : 'polite';
snack.autoHide = !opts.sticky; snack.closeable = !!opts.sticky;
snack.duration = opts.duration || 4000;
pendingClose = opts.onClose || null;
nextTick(() => bar.value.$el.show());
}
function onBarClose(e: CustomEvent<{ reason: string }>) {
const cb = pendingClose; pendingClose = null;
if (cb) cb(e.detail.reason);
}
function startRollup() {
timer = setInterval(() => {
rollup.value = Math.min(100, rollup.value + 2);
if (rollup.value === 100) {
clearInterval(timer!); timer = null;
rollup.state = 'done';
invoices.value = 'running'; // the queue drains: the queued job takes the freed worker
toast('Usage rollup finished');
}
}, 250);
}
onMounted(() => { // stands in for the jobs fetch; skeletons render until replaced
setTimeout(() => { loading.value = false; startRollup(); }, 1400);
});
function confirmCancel() {
confirming.value = false;
if (timer) { clearInterval(timer); timer = null; }
rollup.state = 'cancelled';
toast('Usage rollup cancelled', { action: 'Undo', duration: 6000, onClose: (reason: string) => {
if (reason !== 'action') return; // auto/close = committed
rollup.state = 'running';
startRollup();
} });
}
function runRetry(prev: number) {
const attempt = prev + 1;
hook.attempt = attempt; hook.state = 'retrying';
setTimeout(() => { // stands in for the redelivery call
if (attempt < 4) {
hook.state = 'failed';
toast('Webhook redelivery failed (503)', { assertive: true, sticky: true, action: 'Retry',
onClose: (reason: string) => { if (reason === 'action') runRetry(attempt); } });
} else { hook.state = 'delivered'; toast('Webhook delivered'); }
}, 1600);
}
const rollupText = computed(() => rollup.state === 'running' ? 'Step 3 of 5 - aggregating events (' + rollup.value + '%)'
: rollup.state === 'done' ? 'Completed - 2.1 MB written to exports/usage-2026-07.csv'
: 'Cancelled at ' + rollup.value + '% - partial output discarded');
const hookText = computed(() => hook.state === 'retrying' ? 'Attempt ' + hook.attempt + ' - delivering to hooks.acme-billing.com...'
: hook.state === 'failed' ? 'Attempt ' + hook.attempt + ' failed - endpoint returned 503'
: 'Delivered on attempt ' + hook.attempt + ' - 200 OK in 340 ms');
</script>
<template>
<!-- ONE live region, every skeleton inside it silent -->
<div v-if="loading" role="status" aria-label="Loading export jobs">
<MdSkeleton variant="circular" width="12px" height="12px" :announce="false" />
<MdSkeleton variant="text" :lines="2" :fullWidth="true" :announce="false" />
</div>
<MdList v-else label="Export jobs" interactionMode="multi-action">
<MdListItem headline="Usage rollup - July">
<span slot="leading">
<MdStatusDot :state="rollup.state === 'cancelled' ? 'offline' : 'online'" :live="rollup.state === 'running'"
:label="rollup.state === 'running' ? 'Running' : rollup.state === 'done' ? 'Finished' : 'Cancelled'" />
</span>
<span slot="supporting-text">
<span>{{ rollupText }}</span>
<MdProgressIndicator v-if="rollup.state !== 'cancelled'" :value="rollup.value" :max="100"
:complete="rollup.state === 'done'" label="Usage rollup progress" />
</span>
<MdIconButton v-if="rollup.state === 'running'" slot="trailing" icon="close" size="xs"
aria-label="Cancel the usage rollup export" @md-click="confirming = true" />
</MdListItem>
<MdListItem headline="Invoice PDF batch (412 files)">
<span slot="leading">
<MdStatusDot :state="invoices === 'queued' ? 'neutral' : 'online'" :live="invoices === 'running'"
:label="invoices === 'queued' ? 'Queued' : 'Running'" />
</span>
<span slot="supporting-text">{{ invoices === 'queued' ? 'Queued behind 1 job - no worker assigned yet' : 'Rendering invoice 18 of 412...' }}</span>
<MdLoadingIndicator v-if="invoices === 'queued'" slot="trailing" label="Waiting for a worker" />
</MdListItem>
<MdListItem headline="Webhook redelivery - order.created">
<span slot="leading">
<MdStatusDot :state="hook.state === 'failed' ? 'busy' : 'online'" :live="hook.state === 'retrying'"
:label="hook.state === 'failed' ? 'Failed' : hook.state === 'retrying' ? 'Retrying' : 'Delivered'" />
</span>
<span slot="supporting-text">{{ hookText }}</span>
<MdIconButton v-if="hook.state !== 'delivered'" slot="trailing" icon="refresh" variant="tonal" size="xs"
:disabled="hook.state === 'retrying'" aria-label="Retry webhook redelivery" @md-click="runRetry(hook.attempt)" />
</MdListItem>
</MdList>
<MdSnackbar ref="bar" :message="snack.message" :action="snack.action" :politeness="snack.politeness"
:autoHide="snack.autoHide" :closeable="snack.closeable" :autoHideDuration="snack.duration"
@md-action="bar.$el.hide('action')" @md-close="onBarClose" />
<MdDialog :open="confirming" headline="Cancel this export?" icon="warning" :scrimDismissible="false"
@md-cancel="confirming = false">
<p>"Usage rollup - July" is still running. Cancelling discards the partial file.</p>
<MdButton slot="actions" variant="text" @md-click="confirming = false">Keep running</MdButton>
<MdButton slot="actions" variant="filled" @md-click="confirmCancel">Cancel export</MdButton>
</MdDialog>
</template><script>
// Register the md-* elements once on the client. In SvelteKit, guard it:
// if (browser) defineCustomElements(window);
import { defineCustomElements } from '@awc-ui/svelte';
defineCustomElements(window);
let loading = true;
let rollup = { value: 38, state: 'running' };
let invoices = 'queued';
let hook = { attempt: 2, state: 'failed' };
let confirming = false;
let bar;
let pendingClose = null;
let timer = null;
$: rollupText = rollup.state === 'running' ? 'Step 3 of 5 - aggregating events (' + rollup.value + '%)'
: rollup.state === 'done' ? 'Completed - 2.1 MB written to exports/usage-2026-07.csv'
: 'Cancelled at ' + rollup.value + '% - partial output discarded';
$: hookText = hook.state === 'retrying' ? 'Attempt ' + hook.attempt + ' - delivering to hooks.acme-billing.com...'
: hook.state === 'failed' ? 'Attempt ' + hook.attempt + ' failed - endpoint returned 503'
: 'Delivered on attempt ' + hook.attempt + ' - 200 OK in 340 ms';
// Snackbar conventions: ONE element, one pending-close callback.
// RULE: the action button never dismisses by itself -> hide('action').
// RULE: commit/undo on mdClose by reason, never inside mdAction.
function toast(message, opts = {}) {
bar.message = message; bar.action = opts.action || '';
bar.politeness = opts.assertive ? 'assertive' : 'polite';
bar.autoHide = !opts.sticky; bar.closeable = !!opts.sticky;
bar.autoHideDuration = opts.duration || 4000;
pendingClose = opts.onClose || null;
bar.show();
}
function onBarClose(e) {
const cb = pendingClose; pendingClose = null;
if (cb) cb(e.detail.reason);
}
function startRollup() {
timer = setInterval(() => {
const value = Math.min(100, rollup.value + 2);
rollup = { value, state: value === 100 ? 'done' : 'running' };
if (value === 100) {
clearInterval(timer); timer = null;
invoices = 'running'; // the queue drains: the queued job takes the freed worker
toast('Usage rollup finished');
}
}, 250);
}
// stands in for the jobs fetch; skeletons render until replaced
setTimeout(() => { loading = false; startRollup(); }, 1400);
function confirmCancel() {
confirming = false;
if (timer) { clearInterval(timer); timer = null; }
rollup = { ...rollup, state: 'cancelled' };
toast('Usage rollup cancelled', { action: 'Undo', duration: 6000, onClose: (reason) => {
if (reason !== 'action') return; // auto/close = committed
rollup = { ...rollup, state: 'running' };
startRollup();
} });
}
function runRetry(prev) {
const attempt = prev + 1;
hook = { attempt, state: 'retrying' };
setTimeout(() => { // stands in for the redelivery call
if (attempt < 4) {
hook = { attempt, state: 'failed' };
toast('Webhook redelivery failed (503)', { assertive: true, sticky: true, action: 'Retry',
onClose: (reason) => { if (reason === 'action') runRetry(attempt); } });
} else { hook = { attempt, state: 'delivered' }; toast('Webhook delivered'); }
}, 1600);
}
</script>
{#if loading}
<!-- ONE live region, every skeleton inside it silent -->
<div role="status" aria-label="Loading export jobs">
<md-skeleton variant="circular" width="12px" height="12px" announce="false"></md-skeleton>
<md-skeleton variant="text" lines="2" full-width announce="false"></md-skeleton>
</div>
{:else}
<md-list label="Export jobs" interaction-mode="multi-action">
<md-list-item headline="Usage rollup - July">
<span slot="leading">
<md-status-dot state={rollup.state === 'cancelled' ? 'offline' : 'online'} live={rollup.state === 'running'}
label={rollup.state === 'running' ? 'Running' : rollup.state === 'done' ? 'Finished' : 'Cancelled'}></md-status-dot>
</span>
<span slot="supporting-text">
<span>{rollupText}</span>
{#if rollup.state !== 'cancelled'}
<md-progress-indicator value={rollup.value} max="100" complete={rollup.state === 'done'}
label="Usage rollup progress"></md-progress-indicator>
{/if}
</span>
{#if rollup.state === 'running'}
<md-icon-button slot="trailing" icon="close" size="xs" aria-label="Cancel the usage rollup export"
on:mdClick={() => (confirming = true)}></md-icon-button>
{/if}
</md-list-item>
<md-list-item headline="Invoice PDF batch (412 files)">
<span slot="leading">
<md-status-dot state={invoices === 'queued' ? 'neutral' : 'online'} live={invoices === 'running'}
label={invoices === 'queued' ? 'Queued' : 'Running'}></md-status-dot>
</span>
<span slot="supporting-text">{invoices === 'queued' ? 'Queued behind 1 job - no worker assigned yet' : 'Rendering invoice 18 of 412...'}</span>
{#if invoices === 'queued'}
<md-loading-indicator slot="trailing" label="Waiting for a worker"></md-loading-indicator>
{/if}
</md-list-item>
<md-list-item headline="Webhook redelivery - order.created">
<span slot="leading">
<md-status-dot state={hook.state === 'failed' ? 'busy' : 'online'} live={hook.state === 'retrying'}
label={hook.state === 'failed' ? 'Failed' : hook.state === 'retrying' ? 'Retrying' : 'Delivered'}></md-status-dot>
</span>
<span slot="supporting-text">{hookText}</span>
{#if hook.state !== 'delivered'}
<md-icon-button slot="trailing" icon="refresh" variant="tonal" size="xs" disabled={hook.state === 'retrying'}
aria-label="Retry webhook redelivery" on:mdClick={() => runRetry(hook.attempt)}></md-icon-button>
{/if}
</md-list-item>
</md-list>
{/if}
<md-snackbar bind:this={bar} on:mdAction={() => bar.hide('action')} on:mdClose={onBarClose}></md-snackbar>
<md-dialog open={confirming} headline="Cancel this export?" icon="warning" scrim-dismissible="false"
on:mdCancel={() => (confirming = false)}>
<p>"Usage rollup - July" is still running. Cancelling discards the partial file.</p>
<md-button slot="actions" variant="text" on:mdClick={() => (confirming = false)}>Keep running</md-button>
<md-button slot="actions" variant="filled" on:mdClick={confirmCancel}>Cancel export</md-button>
</md-dialog>How it’s built
Section titled “How it’s built”| Component | Role in this screen |
|---|---|
md-skeleton | The initial paint. Shapes match the real rows (a 12px circle plus two text lines), and one wrapping role="status" region announces the load while every skeleton inside is announce="false". |
md-list + md-list-item | The job queue. interaction-mode="multi-action" lets each row host its own trailing control (cancel, retry) with independent focus and click semantics. |
md-status-dot | Per-job state. The running job’s dot is live + labelled, which makes it a role="status" live region — state changes are announced. Every other dot carries a static label, so no state exists as colour alone. |
md-progress-indicator | Determinate progress for the one measurable job: raw value against max, a text percentage beside the bar, and complete to play the closing animation when it lands. |
md-loading-indicator | The queued job’s indeterminate wait — no fake percentages while no worker is assigned. |
md-icon-button | Inline cancel (icon="close", size="xs") and retry (icon="refresh", tonal) affordances, each with a job-specific aria-label. |
md-dialog | Cancellation confirmation. Destructive, so scrim-dismissible="false" — a stray click outside cannot discard the partial file. |
md-button | The dialog’s slotted actions — dismissive “Keep running” (text) on the leading side of the confirming “Cancel export” (filled). Slotted buttons never close the dialog by themselves; each handler sets open = false itself. |
md-snackbar | One element, three conventions: plain auto-hiding success, sticky assertive error with a Retry action, and a reversible Undo. |
The details that make it production-grade
Section titled “The details that make it production-grade”- One loading region announces once. Twenty skeletons each saying
“Loading” is the most common defect in this pattern — here the wrapping
divis the singlerole="status"element and everymd-skeletoninside it isannounce="false". On failure, replace the skeletons with an error state; they have no timeout of their own. - Completion is explicit, not inferred.
md-progress-indicatorfiresmdCompleteonly after you setcomplete = true— reachingvalue === maxchanges nothing on its own. The demo sets the flag when the rollup lands, which plays the closing animation and removes the bar instead of leaving a finished bar that reads as stalled. - Undo commits on
mdClose, never inmdAction. The snackbar’s action button does not dismiss it; the handler callshide('action')and themdCloselistener branches ondetail.reason. Handling the action in both events runs your rollback twice. - Errors persist, successes evaporate. The failure snackbar is
auto-hide="false"+closeable+politeness="assertive"— a 4-second toast is the wrong vehicle for something that needs a decision. The success message keeps the polite default and disappears on its own. - State is words, not colour. Every
md-status-dotcarries alabel, so WCAG 1.4.1 holds even under forced-colors where all six states collapse to one system colour. Only the genuinely changing dot useslive— a pulse on every row would mean nothing.
Variations
Section titled “Variations”- Several jobs running at once: M3 says show the group total, not a bar
per row — replace the per-job indicators with one linear
md-progress-indicatorabove the list (value="3" max="12" label="Exporting 12 jobs"). - Queued work whose total becomes known: start an
md-progress-indicatorasindeterminate, then clearindeterminatebefore settingvalue/max— it silently overrides the number otherwise. Do not swap anmd-loading-indicatorfor a determinate bar mid-operation; M3 explicitly discourages that transition. - Connection lost: a persistent snackbar —
auto-hide="false",closeable,politeness="assertive"— that youhide()yourself when the socket reconnects. - Dense ops console: move the queue into an
md-tablewithsize="small"status dots and a negative density rung; the dot’s logical insets and the linear track both mirror automatically in RTL.
Saving a form without losing its context
Section titled “Saving a form without losing its context”Keep entered values visible while a real request runs. Use the submit button’s
loading state, make the editable region inert, and keep a dismissive action
available. A single persistent status line announces saving, failure or success;
the button’s built-in spinner is already decorative. On failure, retain the draft
and let the same submit action retry. A skeleton belongs to content that has not
arrived yet, not to a filled-in form the user is saving.
This React example receives saveProfile(data, { signal }) from the application.
It must return a promise for the real request and pass the signal to its transport.
There are no display timers or simulated delays.
import { useEffect, useId, useRef, useState, type FormEvent } from 'react';import { MdButton, MdDialog, MdTextField, useOverlay } from '@awc-ui/react';
interface ProfileDialogProps { saveProfile(data: FormData, options: { signal: AbortSignal }): Promise<unknown>; onClosed(): void;}
export function ProfileDialog({ saveProfile, onClosed }: ProfileDialogProps) { const dialog = useRef<HTMLMdDialogElement>(null); const form = useRef<HTMLFormElement>(null); const name = useRef<HTMLMdTextFieldElement>(null); const request = useRef<AbortController | null>(null); const formId = useId(); const [busy, setBusy] = useState(false); const [failed, setFailed] = useState(false); const [message, setMessage] = useState(''); const overlay = useOverlay(dialog, { onClosing: () => request.current?.abort(), onClosed, }); useEffect(() => () => request.current?.abort(), []);
async function submit(event: FormEvent<HTMLFormElement>) { event.preventDefault(); const currentForm = form.current; const currentName = name.current; if (request.current || !dialog.current?.open || !currentForm || !currentName) return; // Lock before awaiting validation so two activations cannot race. const controller = new AbortController(); request.current = controller; try { if (!(await currentName.reportValidity()) || controller.signal.aborted) return; setBusy(true); setFailed(false); setMessage('Saving your profile…'); await saveProfile(new FormData(currentForm), { signal: controller.signal }); if (!controller.signal.aborted) setMessage('Your profile has been saved.'); } catch (error) { if (!controller.signal.aborted) { setFailed(true); setMessage('Your profile could not be saved. Your changes are still here; try again.'); } } finally { if (request.current === controller) request.current = null; if (!controller.signal.aborted) setBusy(false); } }
return ( <MdDialog ref={dialog} headline="Edit profile" divider {...overlay}> <form id={formId} ref={form} noValidate onSubmit={submit}> <div ref={(element) => { if (element) element.inert = busy; }}> <MdTextField ref={name} name="displayName" label="Display name" required autocomplete="name" reserveSupportingSpace /> </div> <p role="status" aria-atomic="true" style={{ color: failed ? 'var(--md-sys-color-error)' : 'var(--md-sys-color-on-surface-variant)' }}> {message} </p> </form> <MdButton slot="actions" type="button" variant="text" onMdClick={() => dialog.current?.close()}>Close</MdButton> <MdButton slot="actions" type="submit" loading={busy} ref={(button) => { button?.setAttribute('form', formId); }}> {busy ? 'Saving…' : failed ? 'Retry save' : 'Save'} </MdButton> </MdDialog> );}The footer button is associated with the real form through its native form
attribute. The refs set form and the native inert property directly, which
works with the library’s React 18+ support and its React 18 type definitions.
In React 19, the editable region can instead use inert={busy}. Clicking
it and pressing Enter in the single-line field use the same submit handler.
noValidate delegates presentation to the explicit Core reportValidity() call,
which keeps constraint errors inline. Keep the status line outside the inert
region so announcements remain available during the request. Do not add a
second live loader or an error snackbar announcing the same message.
useOverlay ignores nested picker close events, aborts through onClosing, and
calls onClosed only after the dialog’s exit completes. The abort checks prevent
a late response from changing dismissed UI. The caller should unmount the dialog
in onClosed; reopening mounts a fresh session. If saving creates a lasting
server-side job, cancellation of that job needs an application API in addition
to aborting the client request.
Related
Section titled “Related”- Recipe: Server fleet status — status dots and live health at dashboard scale.
- Recipe: CSV import wizard — a single long-running job with staged determinate progress.
- Component manuals:
md-snackbar— the full event contract and queueing rules;md-progress-indicator— thecomplete/mdCompletelifecycle and the determinate/indeterminate switch.