CSV import wizard
The classic “bring your data” flow. A linear
md-stepper walks the user through Upload, Map
columns, Validate, and Import. Each step gates the built-in Continue button on
its own condition — the upload must finish, every CSV column must be mapped —
and the final step runs the import itself, locking navigation with loading
while rows stream in and confirming the result with a snackbar. Nothing
advances past an incomplete step, and the whole wizard resets on Finish.
Live preview — upload, map the third column, then let the import run itself
Upload your CSV
contacts.csv — 214 KB, ready to upload
Upload contacts.csv
Match each CSV column to a contact field
Full name
Email
Company
Phone
Do not import
Full name
Email
Company
Phone
Do not import
Full name
Email
Company
Phone
Do not import
name
email_address
company
Priya Raman
priya.raman@northwind.io
Northwind
Jonas Weber
j.weber@helios-labs.de
Helios Labs
Ana Petrescu
ana.petrescu@cluj.dev
—
Showing the first 3 of 2,148 rows.
2 of 2,148 rows need attention
2,146 rows passed every check and are ready to import.
Error — row 214: "jmoore@@acmemail" is not a valid email address. The row will be skipped.
Warning — row 1,027 duplicates row 312 (same email). The duplicate will be skipped.
Importing into Contacts
The import starts automatically when you reach this step.
Show code for each technology
<!-- index.html — register the AWC UI elements once -->
<script type="module">
import '@awc-ui/core/define';
</script>
<md-stepper next-disabled label="CSV import progress">
<md-step label="Upload" description="Choose your file">
<md-progress-indicator value="0" max="2148" label="Uploading contacts.csv"></md-progress-indicator>
<md-button variant="tonal">Upload contacts.csv</md-button>
</md-step>
<md-step label="Map columns" description="Match CSV to fields">
<md-select label="email_address" value="email">
<md-select-option value="full_name">Full name</md-select-option>
<md-select-option value="email">Email</md-select-option>
<md-select-option value="company">Company</md-select-option>
<md-select-option value="skip">Do not import</md-select-option>
</md-select>
<md-table-container>
<md-table column-template="1fr 1.4fr 1fr" label="CSV preview" density="compact">
<md-table-head>
<md-table-row rowgroup="head">
<md-table-cell head scope="col">name</md-table-cell>
<md-table-cell head scope="col">email_address</md-table-cell>
<md-table-cell head scope="col">company</md-table-cell>
</md-table-row>
</md-table-head>
<md-table-body>
<md-table-row>
<md-table-cell>Priya Raman</md-table-cell>
<md-table-cell>priya.raman@northwind.io</md-table-cell>
<md-table-cell>Northwind</md-table-cell>
</md-table-row>
</md-table-body>
</md-table>
</md-table-container>
</md-step>
<md-step label="Validate" description="Review issues">
<span style="position: relative; display: inline-flex; width: 12px; height: 12px;">
<md-status-dot state="busy"></md-status-dot>
</span>
Error — row 214: invalid email address. The row will be skipped.
</md-step>
<md-step label="Import" description="Bring the data in">
<md-progress-indicator value="0" max="2146" label="Importing contacts"></md-progress-indicator>
</md-step>
</md-stepper>
<md-snackbar closeable auto-hide-duration="6000"></md-snackbar>
<script type="module">
const stepper = document.querySelector('md-stepper');
const uploadBtn = document.querySelector('[data-upload]');
const uploadBar = document.querySelector('[data-upload-progress]');
const uploadStatus = document.querySelector('[data-upload-status]');
const importBar = document.querySelector('[data-import-progress]');
const importStatus = document.querySelector('[data-import-status]');
const snackbar = document.querySelector('[data-result]');
const maps = Array.prototype.slice.call(document.querySelectorAll('[data-map]'));
const TOTAL = 2148; // rows in the file
const VALID = 2146; // rows that survive validation
let uploaded = false;
let importStarted = false;
let imported = false;
let uploadTimer = null;
let importTimer = null;
const fmt = (n) => n.toLocaleString('en-US');
// One gate for the built-in Continue button, re-evaluated per step.
function syncNext() {
const i = stepper.active;
if (i === 0) stepper.nextDisabled = !uploaded;
else if (i === 1) stepper.nextDisabled = maps.some((s) => !s.value);
else if (i === 2) stepper.nextDisabled = false;
else stepper.nextDisabled = !imported;
}
// Step 1: simulated upload with a determinate progress bar.
uploadBtn.addEventListener('click', () => {
if (uploadTimer || uploaded) return;
uploadBtn.disabled = true;
let sent = 0;
uploadTimer = setInterval(() => {
sent = Math.min(TOTAL, sent + 179);
uploadBar.value = sent;
if (sent < TOTAL) {
uploadStatus.textContent = 'Uploading contacts.csv — ' + fmt(sent) + ' of ' + fmt(TOTAL) + ' rows';
} else {
clearInterval(uploadTimer);
uploadTimer = null;
uploaded = true;
uploadStatus.textContent = 'contacts.csv uploaded — ' + fmt(TOTAL) + ' rows, 3 columns detected.';
uploadBtn.textContent = 'Uploaded';
syncNext();
}
}, 90);
});
// Step 2: Continue unlocks once every column has a mapping.
maps.forEach((sel) => sel.addEventListener('mdChange', syncNext));
// Step 4: the import runs itself on arrival. The stepper's loading state
// locks Back and Continue while rows stream in — no double-submit.
function startImport() {
importStarted = true;
stepper.loading = true;
let done = 0;
importTimer = setInterval(() => {
done = Math.min(VALID, done + 143);
importBar.value = done;
if (done < VALID) {
importStatus.textContent = 'Importing — ' + fmt(done) + ' of ' + fmt(VALID) + ' contacts';
} else {
clearInterval(importTimer);
importTimer = null;
imported = true;
stepper.loading = false;
importStatus.textContent = fmt(VALID) + ' contacts imported. 2 rows were skipped. Press Finish to run the demo again.';
snackbar.message = fmt(VALID) + ' contacts imported · 2 rows skipped';
snackbar.show();
syncNext();
}
}, 80);
}
stepper.addEventListener('mdStepChange', (e) => {
syncNext();
if (e.detail.index === 3 && !importStarted) startImport();
});
// Finish resets the whole demo so it can be run again.
stepper.addEventListener('mdComplete', () => {
if (uploadTimer) { clearInterval(uploadTimer); uploadTimer = null; }
if (importTimer) { clearInterval(importTimer); importTimer = null; }
uploaded = false;
importStarted = false;
imported = false;
uploadBar.value = 0;
importBar.value = 0;
uploadBtn.disabled = false;
uploadBtn.textContent = 'Upload contacts.csv';
uploadStatus.textContent = 'contacts.csv — 214 KB, ready to upload';
importStatus.textContent = 'The import starts automatically when you reach this step.';
stepper.reset().then(syncNext);
});
</script>import { useRef, useState } from 'react';
import {
MdButton, MdProgressIndicator, MdSelect, MdSelectOption, MdSnackbar,
MdStatusDot, MdStep, MdStepper, MdTable, MdTableBody, MdTableCell,
MdTableContainer, MdTableHead, MdTableRow,
} from '@awc-ui/react';
const TOTAL = 2148; // rows in the file
const VALID = 2146; // rows that survive validation
const FIELDS = [
['full_name', 'Full name'], ['email', 'Email'], ['company', 'Company'],
['phone', 'Phone'], ['skip', 'Do not import'],
];
const fmt = (n) => n.toLocaleString('en-US');
const dotWrap = { position: 'relative', display: 'inline-flex', width: 12, height: 12 };
export function CsvImportWizard() {
const stepper = useRef(null);
const snackbar = useRef(null);
const [active, setActive] = useState(0);
const [sent, setSent] = useState(0);
const [uploaded, setUploaded] = useState(false);
const [mapping, setMapping] = useState({ name: 'full_name', email_address: 'email', company: '' });
const [done, setDone] = useState(0);
const [importing, setImporting] = useState(false);
const [imported, setImported] = useState(false);
const allMapped = Object.values(mapping).every(Boolean);
const nextDisabled =
active === 0 ? !uploaded : active === 1 ? !allMapped : active === 2 ? false : !imported;
function startUpload() {
const t = setInterval(() => setSent((s) => {
const next = Math.min(TOTAL, s + 179);
if (next === TOTAL) { clearInterval(t); setUploaded(true); }
return next;
}), 90);
}
// The import runs itself on arrival; loading locks Back and Continue meanwhile.
function startImport() {
setImporting(true);
const t = setInterval(() => setDone((d) => {
const next = Math.min(VALID, d + 143);
if (next === VALID) {
clearInterval(t); setImporting(false); setImported(true);
snackbar.current?.show();
}
return next;
}), 80);
}
function onStepChange(e) {
setActive(e.detail.index);
if (e.detail.index === 3 && !importing && !imported) startImport();
}
function onFinish() { // Finish resets the whole flow
setSent(0); setUploaded(false); setDone(0); setImported(false);
setMapping({ name: 'full_name', email_address: 'email', company: '' });
stepper.current?.reset();
setActive(0);
}
const uploadStatus = uploaded
? 'contacts.csv uploaded — ' + fmt(TOTAL) + ' rows, 3 columns detected.'
: sent > 0
? 'Uploading contacts.csv — ' + fmt(sent) + ' of ' + fmt(TOTAL) + ' rows'
: 'contacts.csv — 214 KB, ready to upload';
const importStatus = imported
? fmt(VALID) + ' contacts imported. 2 rows were skipped. Press Finish to run the demo again.'
: importing
? 'Importing — ' + fmt(done) + ' of ' + fmt(VALID) + ' contacts'
: 'The import starts automatically when you reach this step.';
return (
<>
<MdStepper ref={stepper} label="CSV import progress" nextDisabled={nextDisabled}
loading={importing} onMdStepChange={onStepChange} onMdComplete={onFinish}>
<MdStep label="Upload" description="Choose your file">
<p>{uploadStatus}</p>
<MdProgressIndicator value={sent} max={TOTAL} label="Uploading contacts.csv" />
<MdButton variant="tonal" disabled={sent > 0} onMdClick={startUpload}>
{uploaded ? 'Uploaded' : 'Upload contacts.csv'}
</MdButton>
</MdStep>
<MdStep label="Map columns" description="Match CSV to fields">
{Object.keys(mapping).map((col) => (
<MdSelect key={col} label={col} value={mapping[col]} placeholder="Choose a field"
onMdChange={(e) => setMapping({ ...mapping, [col]: e.detail })}>
{FIELDS.map(([v, text]) => <MdSelectOption key={v} value={v}>{text}</MdSelectOption>)}
</MdSelect>
))}
<MdTableContainer>
<MdTable columnTemplate="1fr 1.4fr 1fr" label="CSV preview" density="compact">
<MdTableHead>
<MdTableRow rowgroup="head">
<MdTableCell head scope="col">name</MdTableCell>
<MdTableCell head scope="col">email_address</MdTableCell>
<MdTableCell head scope="col">company</MdTableCell>
</MdTableRow>
</MdTableHead>
<MdTableBody>
<MdTableRow>
<MdTableCell>Priya Raman</MdTableCell>
<MdTableCell>priya.raman@northwind.io</MdTableCell>
<MdTableCell>Northwind</MdTableCell>
</MdTableRow>
</MdTableBody>
</MdTable>
</MdTableContainer>
</MdStep>
<MdStep label="Validate" description="Review issues">
<p><span style={dotWrap}><MdStatusDot state="online" /></span> 2,146 rows passed every check and are ready to import.</p>
<p><span style={dotWrap}><MdStatusDot state="busy" /></span> Error — row 214: the email address is invalid. The row will be skipped.</p>
<p><span style={dotWrap}><MdStatusDot state="away" /></span> Warning — row 1,027 duplicates row 312 (same email). The duplicate will be skipped.</p>
</MdStep>
<MdStep label="Import" description="Bring the data in">
<MdProgressIndicator value={done} max={VALID} label="Importing contacts" />
<p>{importStatus}</p>
</MdStep>
</MdStepper>
<MdSnackbar ref={snackbar} closeable autoHideDuration={6000}
message={fmt(VALID) + ' contacts imported · 2 rows skipped'} />
</>
);
}import { Component, ViewChild } from '@angular/core';
import { FormsModule } from '@angular/forms';
import {
MdButton, MdProgressIndicator, MdSelect, MdSelectOption, MdSnackbar,
MdStatusDot, MdStep, MdStepper, MdTable, MdTableBody, MdTableCell,
MdTableContainer, MdTableHead, MdTableRow, SelectValueAccessor,
} from '@awc-ui/angular';
const initialColumns = () => [
{ label: 'name', value: 'full_name' },
{ label: 'email_address', value: 'email' },
{ label: 'company', value: '' },
];
@Component({
selector: 'app-csv-import-wizard',
standalone: true,
imports: [
FormsModule, SelectValueAccessor, MdButton, MdProgressIndicator, MdSelect,
MdSelectOption, MdSnackbar, MdStatusDot, MdStep, MdStepper, MdTable,
MdTableBody, MdTableCell, MdTableContainer, MdTableHead, MdTableRow,
],
template:
'<md-stepper label="CSV import progress" [nextDisabled]="nextDisabled" ' +
' [loading]="importing" (mdStepChange)="onStepChange($event)" (mdComplete)="onFinish()">' +
' <md-step label="Upload" description="Choose your file">' +
' <p>{{ uploadStatus }}</p>' +
' <md-progress-indicator [value]="sent" [max]="total" label="Uploading contacts.csv"></md-progress-indicator>' +
' <md-button variant="tonal" [disabled]="sent > 0" (mdClick)="startUpload()">{{ uploaded ? "Uploaded" : "Upload contacts.csv" }}</md-button>' +
' </md-step>' +
' <md-step label="Map columns" description="Match CSV to fields">' +
' @for (col of columns; track col.label) {' +
' <md-select [label]="col.label" [(ngModel)]="col.value" placeholder="Choose a field">' +
' @for (f of fields; track f.value) {' +
' <md-select-option [value]="f.value">{{ f.label }}</md-select-option>' +
' }' +
' </md-select>' +
' }' +
' <md-table-container>' +
' <md-table columnTemplate="1fr 1.4fr 1fr" label="CSV preview" density="compact">' +
' <md-table-head><md-table-row rowgroup="head">' +
' <md-table-cell [head]="true" scope="col">name</md-table-cell>' +
' <md-table-cell [head]="true" scope="col">email_address</md-table-cell>' +
' <md-table-cell [head]="true" scope="col">company</md-table-cell>' +
' </md-table-row></md-table-head>' +
' <md-table-body><md-table-row>' +
' <md-table-cell>Priya Raman</md-table-cell>' +
' <md-table-cell>priya.raman@northwind.io</md-table-cell>' +
' <md-table-cell>Northwind</md-table-cell>' +
' </md-table-row></md-table-body>' +
' </md-table>' +
' </md-table-container>' +
' </md-step>' +
' <md-step label="Validate" description="Review issues">' +
' <p><span class="dot"><md-status-dot state="online"></md-status-dot></span> 2,146 rows passed every check and are ready to import.</p>' +
' <p><span class="dot"><md-status-dot state="busy"></md-status-dot></span> Error — row 214: the email address is invalid. The row will be skipped.</p>' +
' <p><span class="dot"><md-status-dot state="away"></md-status-dot></span> Warning — row 1,027 duplicates row 312 (same email). The duplicate will be skipped.</p>' +
' </md-step>' +
' <md-step label="Import" description="Bring the data in">' +
' <md-progress-indicator [value]="done" [max]="valid" label="Importing contacts"></md-progress-indicator>' +
' <p>{{ importStatus }}</p>' +
' </md-step>' +
'</md-stepper>' +
'<md-snackbar [closeable]="true" [autoHideDuration]="6000" [message]="resultMsg"></md-snackbar>',
styles: ['.dot { position: relative; display: inline-flex; width: 12px; height: 12px; }'],
})
export class CsvImportWizardComponent {
@ViewChild(MdStepper) stepper!: MdStepper;
@ViewChild(MdSnackbar) snackbar!: MdSnackbar;
total = 2148; // rows in the file
valid = 2146; // rows that survive validation
active = 0;
sent = 0;
uploaded = false;
done = 0;
importing = false;
imported = false;
columns = initialColumns();
fields = [
{ value: 'full_name', label: 'Full name' }, { value: 'email', label: 'Email' },
{ value: 'company', label: 'Company' }, { value: 'phone', label: 'Phone' },
{ value: 'skip', label: 'Do not import' },
];
resultMsg = '2,146 contacts imported · 2 rows skipped';
get nextDisabled(): boolean {
if (this.active === 0) return !this.uploaded;
if (this.active === 1) return this.columns.some((c) => !c.value);
if (this.active === 2) return false;
return !this.imported;
}
get uploadStatus(): string {
if (this.uploaded) return 'contacts.csv uploaded — ' + this.fmt(this.total) + ' rows, 3 columns detected.';
if (this.sent > 0) return 'Uploading contacts.csv — ' + this.fmt(this.sent) + ' of ' + this.fmt(this.total) + ' rows';
return 'contacts.csv — 214 KB, ready to upload';
}
get importStatus(): string {
if (this.imported) return this.fmt(this.valid) + ' contacts imported. 2 rows were skipped. Press Finish to run the demo again.';
if (this.importing) return 'Importing — ' + this.fmt(this.done) + ' of ' + this.fmt(this.valid) + ' contacts';
return 'The import starts automatically when you reach this step.';
}
fmt(n: number): string { return n.toLocaleString('en-US'); }
startUpload(): void {
const t = setInterval(() => {
this.sent = Math.min(this.total, this.sent + 179);
if (this.sent === this.total) { clearInterval(t); this.uploaded = true; }
}, 90);
}
onStepChange(e: CustomEvent<{ index: number }>): void {
this.active = e.detail.index;
if (this.active === 3 && !this.importing && !this.imported) this.startImport();
}
startImport(): void { // loading locks Back and Continue while rows stream in
this.importing = true;
const t = setInterval(() => {
this.done = Math.min(this.valid, this.done + 143);
if (this.done === this.valid) {
clearInterval(t);
this.importing = false;
this.imported = true;
this.snackbar.show();
}
}, 80);
}
onFinish(): void { // Finish resets the whole flow
this.sent = 0; this.uploaded = false; this.done = 0; this.imported = false;
this.columns = initialColumns();
this.stepper.reset();
this.active = 0;
}
}<script setup lang="ts">
import { computed, reactive, ref } from 'vue';
import {
MdButton, MdProgressIndicator, MdSelect, MdSelectOption, MdSnackbar,
MdStatusDot, MdStep, MdStepper, MdTable, MdTableBody, MdTableCell,
MdTableContainer, MdTableHead, MdTableRow,
} from '@awc-ui/vue';
const TOTAL = 2148; // rows in the file
const VALID = 2146; // rows that survive validation
const FIELDS = [
{ value: 'full_name', label: 'Full name' }, { value: 'email', label: 'Email' },
{ value: 'company', label: 'Company' }, { value: 'phone', label: 'Phone' },
{ value: 'skip', label: 'Do not import' },
];
const stepper = ref(); // wrapper component — its $el is the md-stepper element
const snackbar = ref();
const active = ref(0);
const sent = ref(0);
const uploaded = ref(false);
const done = ref(0);
const importing = ref(false);
const imported = ref(false);
const columns = reactive([
{ label: 'name', value: 'full_name' },
{ label: 'email_address', value: 'email' },
{ label: 'company', value: '' },
]);
const fmt = (n: number) => n.toLocaleString('en-US');
const nextDisabled = computed(() => {
if (active.value === 0) return !uploaded.value;
if (active.value === 1) return columns.some((c) => !c.value);
if (active.value === 2) return false;
return !imported.value;
});
const uploadStatus = computed(() =>
uploaded.value ? 'contacts.csv uploaded — ' + fmt(TOTAL) + ' rows, 3 columns detected.'
: sent.value > 0 ? 'Uploading contacts.csv — ' + fmt(sent.value) + ' of ' + fmt(TOTAL) + ' rows'
: 'contacts.csv — 214 KB, ready to upload');
const importStatus = computed(() =>
imported.value ? fmt(VALID) + ' contacts imported. 2 rows were skipped. Press Finish to run the demo again.'
: importing.value ? 'Importing — ' + fmt(done.value) + ' of ' + fmt(VALID) + ' contacts'
: 'The import starts automatically when you reach this step.');
function startUpload() {
const t = setInterval(() => {
sent.value = Math.min(TOTAL, sent.value + 179);
if (sent.value === TOTAL) { clearInterval(t); uploaded.value = true; }
}, 90);
}
function onStepChange(e: CustomEvent<{ index: number }>) {
active.value = e.detail.index;
if (active.value === 3 && !importing.value && !imported.value) startImport();
}
function startImport() { // loading locks Back and Continue while rows stream in
importing.value = true;
const t = setInterval(() => {
done.value = Math.min(VALID, done.value + 143);
if (done.value === VALID) {
clearInterval(t);
importing.value = false;
imported.value = true;
snackbar.value.$el.show();
}
}, 80);
}
function onFinish() { // Finish resets the whole flow
sent.value = 0; uploaded.value = false; done.value = 0; imported.value = false;
columns.forEach((c, i) => (c.value = i === 0 ? 'full_name' : i === 1 ? 'email' : ''));
stepper.value.$el.reset();
active.value = 0;
}
</script>
<template>
<MdStepper ref="stepper" label="CSV import progress" :nextDisabled="nextDisabled"
:loading="importing" @md-step-change="onStepChange" @md-complete="onFinish">
<MdStep label="Upload" description="Choose your file">
<p>{{ uploadStatus }}</p>
<MdProgressIndicator :value="sent" :max="TOTAL" label="Uploading contacts.csv" />
<MdButton variant="tonal" :disabled="sent > 0" @md-click="startUpload">
{{ uploaded ? 'Uploaded' : 'Upload contacts.csv' }}
</MdButton>
</MdStep>
<MdStep label="Map columns" description="Match CSV to fields">
<MdSelect v-for="col in columns" :key="col.label" :label="col.label" :value="col.value"
placeholder="Choose a field" @md-change="col.value = $event.detail">
<MdSelectOption v-for="f in FIELDS" :key="f.value" :value="f.value">{{ f.label }}</MdSelectOption>
</MdSelect>
<MdTableContainer>
<MdTable columnTemplate="1fr 1.4fr 1fr" label="CSV preview" density="compact">
<MdTableHead>
<MdTableRow rowgroup="head">
<MdTableCell :head="true" scope="col">name</MdTableCell>
<MdTableCell :head="true" scope="col">email_address</MdTableCell>
<MdTableCell :head="true" scope="col">company</MdTableCell>
</MdTableRow>
</MdTableHead>
<MdTableBody>
<MdTableRow>
<MdTableCell>Priya Raman</MdTableCell>
<MdTableCell>priya.raman@northwind.io</MdTableCell>
<MdTableCell>Northwind</MdTableCell>
</MdTableRow>
</MdTableBody>
</MdTable>
</MdTableContainer>
</MdStep>
<MdStep label="Validate" description="Review issues">
<p><span class="dot"><MdStatusDot state="online" /></span> 2,146 rows passed every check and are ready to import.</p>
<p><span class="dot"><MdStatusDot state="busy" /></span> Error — row 214: the email address is invalid. The row will be skipped.</p>
<p><span class="dot"><MdStatusDot state="away" /></span> Warning — row 1,027 duplicates row 312 (same email). The duplicate will be skipped.</p>
</MdStep>
<MdStep label="Import" description="Bring the data in">
<MdProgressIndicator :value="done" :max="VALID" label="Importing contacts" />
<p>{{ importStatus }}</p>
</MdStep>
</MdStepper>
<MdSnackbar ref="snackbar" :closeable="true" :autoHideDuration="6000"
:message="fmt(VALID) + ' contacts imported · 2 rows skipped'" />
</template>
<style scoped>
.dot { position: relative; display: inline-flex; width: 12px; height: 12px; }
</style><script lang="ts">
// The package registers the raw md-* elements. Client-only — in SvelteKit,
// guard with: import { browser } from '$app/environment'; if (browser) ...
import { defineCustomElements } from '@awc-ui/svelte';
if (typeof window !== 'undefined') defineCustomElements(window);
const TOTAL = 2148; // rows in the file
const VALID = 2146; // rows that survive validation
const FIELDS = [
{ value: 'full_name', label: 'Full name' }, { value: 'email', label: 'Email' },
{ value: 'company', label: 'Company' }, { value: 'phone', label: 'Phone' },
{ value: 'skip', label: 'Do not import' },
];
let stepperEl: HTMLElement & { reset: () => Promise<void> };
let snackbarEl: HTMLElement & { message: string; show: () => Promise<void> };
let active = 0;
let sent = 0;
let uploaded = false;
let done = 0;
let importing = false;
let imported = false;
let columns = [
{ label: 'name', value: 'full_name' },
{ label: 'email_address', value: 'email' },
{ label: 'company', value: '' },
];
const fmt = (n: number) => n.toLocaleString('en-US');
$: nextDisabled =
active === 0 ? !uploaded
: active === 1 ? columns.some((c) => !c.value)
: active === 2 ? false
: !imported;
$: uploadStatus = uploaded
? 'contacts.csv uploaded — ' + fmt(TOTAL) + ' rows, 3 columns detected.'
: sent > 0 ? 'Uploading contacts.csv — ' + fmt(sent) + ' of ' + fmt(TOTAL) + ' rows'
: 'contacts.csv — 214 KB, ready to upload';
$: importStatus = imported
? fmt(VALID) + ' contacts imported. 2 rows were skipped. Press Finish to run the demo again.'
: importing ? 'Importing — ' + fmt(done) + ' of ' + fmt(VALID) + ' contacts'
: 'The import starts automatically when you reach this step.';
function startUpload() {
const t = setInterval(() => {
sent = Math.min(TOTAL, sent + 179);
if (sent === TOTAL) { clearInterval(t); uploaded = true; }
}, 90);
}
function onStepChange(e: CustomEvent<{ index: number }>) {
active = e.detail.index;
if (active === 3 && !importing && !imported) startImport();
}
function startImport() { // loading locks Back and Continue while rows stream in
importing = true;
const t = setInterval(() => {
done = Math.min(VALID, done + 143);
if (done === VALID) {
clearInterval(t);
importing = false;
imported = true;
snackbarEl.message = fmt(VALID) + ' contacts imported · 2 rows skipped';
snackbarEl.show();
}
}, 80);
}
function setColumn(i: number, value: string) {
columns[i].value = value;
columns = columns; // reassign so the gate recomputes
}
function onFinish() { // Finish resets the whole flow
sent = 0; uploaded = false; done = 0; imported = false;
columns = [
{ label: 'name', value: 'full_name' },
{ label: 'email_address', value: 'email' },
{ label: 'company', value: '' },
];
stepperEl.reset();
active = 0;
}
</script>
<md-stepper bind:this={stepperEl} label="CSV import progress" next-disabled={nextDisabled}
loading={importing} on:mdStepChange={onStepChange} on:mdComplete={onFinish}>
<md-step label="Upload" description="Choose your file">
<p>{uploadStatus}</p>
<md-progress-indicator value={sent} max={TOTAL} label="Uploading contacts.csv"></md-progress-indicator>
<md-button variant="tonal" disabled={sent > 0} on:mdClick={startUpload}>
{uploaded ? 'Uploaded' : 'Upload contacts.csv'}
</md-button>
</md-step>
<md-step label="Map columns" description="Match CSV to fields">
{#each columns as col, i (col.label)}
<md-select label={col.label} value={col.value} placeholder="Choose a field"
on:mdChange={(e) => setColumn(i, e.detail)}>
{#each FIELDS as f (f.value)}
<md-select-option value={f.value}>{f.label}</md-select-option>
{/each}
</md-select>
{/each}
<md-table-container>
<md-table column-template="1fr 1.4fr 1fr" label="CSV preview" density="compact">
<md-table-head>
<md-table-row rowgroup="head">
<md-table-cell head scope="col">name</md-table-cell>
<md-table-cell head scope="col">email_address</md-table-cell>
<md-table-cell head scope="col">company</md-table-cell>
</md-table-row>
</md-table-head>
<md-table-body>
<md-table-row>
<md-table-cell>Priya Raman</md-table-cell>
<md-table-cell>priya.raman@northwind.io</md-table-cell>
<md-table-cell>Northwind</md-table-cell>
</md-table-row>
</md-table-body>
</md-table>
</md-table-container>
</md-step>
<md-step label="Validate" description="Review issues">
<p><span class="dot"><md-status-dot state="online"></md-status-dot></span> 2,146 rows passed every check and are ready to import.</p>
<p><span class="dot"><md-status-dot state="busy"></md-status-dot></span> Error — row 214: the email address is invalid. The row will be skipped.</p>
<p><span class="dot"><md-status-dot state="away"></md-status-dot></span> Warning — row 1,027 duplicates row 312 (same email). The duplicate will be skipped.</p>
</md-step>
<md-step label="Import" description="Bring the data in">
<md-progress-indicator value={done} max={VALID} label="Importing contacts"></md-progress-indicator>
<p>{importStatus}</p>
</md-step>
</md-stepper>
<md-snackbar bind:this={snackbarEl} closeable auto-hide-duration="6000"></md-snackbar>
<style>
.dot { position: relative; display: inline-flex; width: 12px; height: 12px; }
</style>How it’s built
Section titled “How it’s built”| Component | Role in this screen |
|---|---|
md-stepper | The spine. mode="linear" (the default) keeps future step headers unreachable, next-disabled gates the built-in Continue per step, loading locks both nav buttons during the import, and mdComplete on Finish resets the flow via reset(). Content inside each md-step gives every step a full-width panel under the header row. |
md-progress-indicator | Two determinate bars fed raw counts (value="1432" max="2148") rather than pre-computed percentages — the fraction is clamped internally and published as aria-valuenow / aria-valuemax. |
md-select | One mapping control per CSV column. mdChange re-evaluates the Continue gate, placeholder + supporting-text flag the still-unmapped column, and options carry stable value identifiers separate from their labels. |
md-table | A compact (density="compact") three-column preview of the first rows, with explicit tracks via column-template and proper head scope="col" header cells. |
md-status-dot | Per-finding severity in the validation report — green for clean rows, red for the hard error, amber for the duplicate warning. Each dot sits in a position: relative wrapper span, as the dot positions itself absolutely. |
md-snackbar | The import result: low-priority confirmation that auto-hides after 6 s and carries a close button. |
The details that make it production-grade
Section titled “The details that make it production-grade”- Two gates, no gaps.
next-disabledonly disables the built-in Continue button — step-header clicks are unaffected. That is why the stepper stays inmode="linear": unreachable headers arearia-disabledand out of the tab order, so there is no route past an incomplete step. loadingprevents a double import. While rows stream in, the Continue / Finish button shows a spinner and Back is disabled too — the user cannot re-enter the import step or leave mid-write.- The stepper announces itself. The host is a
navigationlandmark named bylabel, and a polite live region announces every move (“Step 2 of 4: Map columns, current”), with an assertive one reserved for steps enteringerror. - The status dots are decorative on purpose. With no
labelthey renderrole="presentation", and the row text carries the words (“Error — row 214…”) — so severity is never conveyed by colour alone (WCAG 1.4.1), which also holds under forced-colors mode where every dot collapses to one colour. - The snackbar respects slow readers. Its auto-hide timer pauses while the surface is hovered or holds focus (WCAG 2.2.1), and the result also stays visible as text in the import panel, so nothing depends on catching a toast.
Variations
Section titled “Variations”- Narrow screens:
variant="mobile"swaps the header row for a compact Back · progress · Continue bar; move the panels into the stepper’scontentslot and swap them onmdStepChange. - Vertical layout:
orientation="vertical"renders each panel inline under its step with per-step Back / Continue — better when panels are short and the flow is embedded in a settings page. - Server-confirmed stages: set
auto-complete="false"and mark eachmd-stepcompletedyourself only after the backend acknowledges it, so steps never look done before they are. - Revisitable mapping: add
editableto the Map columns step — users can step back to adjust a mapping without un-completing the validation step behind them.
Related
Section titled “Related”- Recipe: Two-factor verification — gating a flow on validation, with loading and error states.
- Component manuals:
md-stepper— the step advancement API,mdBeforeChangevalidation hook, and panel placement rules;md-select— filtering, async loading, and form participation.