Appointment booking
The classic wizard, done properly. A physiotherapy clinic’s booking screen
built around md-stepper: pick a service from
interactive md-card tiles, choose a slot with
md-date-picker (September–October window,
weekends disabled via isDateDisabled) and a 24-hour
md-time-picker snapped to 30-minute slots,
review a summary, and confirm. Success lands in an
md-dialog whose
md-split-button downloads a real .ics file —
or opens a menu of calendar variations. Continue stays disabled until each
step’s data exists, and mode="linear" keeps step headers honest.
Live preview — pick a service, then a weekday slot in Sep–Oct 2026
Book an appointment
Meridian Physio · Mon–Fri 09:00–17:00 · 14 Arcului St, Bucharest
Service—
When—
Duration—
Price—
WhereMeridian Physio, 14 Arcului St, Bucharest
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>
<md-stepper label="Booking progress" finish-label="Book appointment" next-disabled>
<md-step label="Service" description="What do you need?">
<md-card interactive variant="outlined" aria-label="Choose initial assessment, 60 minutes, 70 euro">
<span>Initial assessment</span>
<span>60 min · €70</span>
</md-card>
</md-step>
<md-step label="Date & time" description="Pick a free slot">
<md-date-picker label="Date" required clearable
min="2026-09-01" max="2026-10-30"
locale="en-GB" first-day-of-week="1"
supporting-text="Monday to Friday" reserve-supporting-space
></md-date-picker>
<md-time-picker label="Time" required
format="24h" min="09:00" max="16:30" minute-step="30"
></md-time-picker>
</md-step>
<md-step label="Confirm" description="Review your booking">
Summary rows go here.
</md-step>
</md-stepper>
<md-dialog headline="Appointment booked" icon="event_available" scrim-dismissible="false">
<p>Initial assessment on Tuesday 15 September 2026 at 09:30.</p>
<md-split-button id="cal-split" variant="tonal" icon="event" label="Add to calendar"
menu-label="More calendar options" controls="cal-menu"></md-split-button>
<md-button slot="actions" variant="text">Done</md-button>
</md-dialog>
<md-menu id="cal-menu" anchor="cal-split" placement="bottom-start">
<md-menu-item headline="Download .ics file"></md-menu-item>
<md-menu-item headline="Copy details"></md-menu-item>
</md-menu>
<script type="module">
var stepper = document.querySelector('[data-wizard]');
var cards = Array.prototype.slice.call(document.querySelectorAll('md-card[data-service]'));
var datePicker = document.querySelector('[data-date]');
var timePicker = document.querySelector('[data-time]');
var dialog = document.querySelector('[data-confirm]');
var confirmText = document.querySelector('[data-confirm-text]');
var split = document.getElementById('cal-split');
var menu = document.getElementById('cal-menu');
var status = document.querySelector('[data-cal-status]');
var booking = { service: '', duration: 0, price: '', date: '', time: '' };
var activeIndex = 0;
var days = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'];
var months = ['January', 'February', 'March', 'April', 'May', 'June',
'July', 'August', 'September', 'October', 'November', 'December'];
function formatDate(iso) {
var p = iso.split('-');
var dt = new Date(+p[0], p[1] - 1, +p[2]);
return days[dt.getDay()] + ' ' + (+p[2]) + ' ' + months[p[1] - 1] + ' ' + p[0];
}
// The clinic is closed on weekends — a function property, not an attribute.
datePicker.isDateDisabled = function (d) {
return d.getDay() === 0 || d.getDay() === 6;
};
// Continue is gated per step: a service on step 1, a full slot on step 2.
function updateGate() {
if (activeIndex === 0) stepper.nextDisabled = !booking.service;
else if (activeIndex === 1) stepper.nextDisabled = !(booking.date && booking.time);
else stepper.nextDisabled = false;
}
cards.forEach(function (card) {
card.addEventListener('mdClick', function () {
cards.forEach(function (c) { c.variant = c === card ? 'filled' : 'outlined'; });
booking.service = card.dataset.service;
booking.duration = +card.dataset.duration;
booking.price = card.dataset.price;
updateGate();
});
});
// mdChange is the commit event — mdSelected / mdInput would fire mid-pick.
datePicker.addEventListener('mdChange', function (e) { booking.date = e.detail.value; updateGate(); });
timePicker.addEventListener('mdChange', function (e) { booking.time = e.detail.value; updateGate(); });
function fillSummary() {
document.querySelector('[data-sum-service]').textContent = booking.service;
document.querySelector('[data-sum-when]').textContent = formatDate(booking.date) + ', ' + booking.time;
document.querySelector('[data-sum-duration]').textContent = booking.duration + ' minutes';
document.querySelector('[data-sum-price]').textContent = booking.price;
}
stepper.addEventListener('mdStepChange', function (e) {
activeIndex = e.detail.index;
if (activeIndex === 2) fillSummary();
updateGate();
});
// "Book appointment" (the finish-label) pressed on the last step.
stepper.addEventListener('mdComplete', function () {
confirmText.textContent = booking.service + ' with Dr. Elena Rusu on ' +
formatDate(booking.date) + ' at ' + booking.time +
'. A confirmation email is on its way to you.';
dialog.open = true;
});
document.querySelector('[data-dialog-done]').addEventListener('click', function () {
dialog.open = false;
});
// Split button: leading segment runs the default action, trailing drives the menu.
function pad(n) { return (n < 10 ? '0' : '') + n; }
function stamp(dt) {
return '' + dt.getFullYear() + pad(dt.getMonth() + 1) + pad(dt.getDate()) +
'T' + pad(dt.getHours()) + pad(dt.getMinutes()) + '00';
}
function setStatus(msg) { status.hidden = false; status.textContent = msg; }
function downloadIcs() {
var p = booking.date.split('-');
var t = booking.time.split(':');
var start = new Date(+p[0], p[1] - 1, +p[2], +t[0], +t[1]);
var end = new Date(start.getTime() + booking.duration * 60000);
var lines = [
'BEGIN:VCALENDAR', 'VERSION:2.0', 'PRODID:-//Meridian Physio//Booking//EN',
'BEGIN:VEVENT',
'UID:' + Date.now() + '@meridian-physio.example',
'DTSTART:' + stamp(start),
'DTEND:' + stamp(end),
'SUMMARY:' + booking.service + ' — Meridian Physio',
'LOCATION:Meridian Physio – 14 Arcului St (Bucharest)',
'END:VEVENT', 'END:VCALENDAR'
];
var a = document.createElement('a');
a.href = 'data:text/calendar;charset=utf-8,' + encodeURIComponent(lines.join('\r\n'));
a.download = 'meridian-appointment.ics';
a.click();
setStatus('meridian-appointment.ics saved — open it to add the event.');
}
split.addEventListener('mdLeadingClick', downloadIcs);
split.addEventListener('mdTrailingClick', function (e) {
if (e.detail.checked) menu.show(); else menu.close();
});
// mdClose does not bubble — listen on the menu itself, and keep the chevron honest.
menu.addEventListener('mdClose', function () { split.trailingChecked = false; });
document.querySelector('[data-ics]').addEventListener('mdClick', downloadIcs);
document.querySelector('[data-copy-details]').addEventListener('mdClick', function () {
var text = booking.service + ' — ' + formatDate(booking.date) + ' ' + booking.time +
', Meridian Physio, 14 Arcului St, Bucharest';
if (navigator.clipboard) navigator.clipboard.writeText(text);
setStatus('Details copied to the clipboard.');
});
</script>import { useState } from 'react';
import {
MdButton, MdCard, MdDatePicker, MdDialog, MdMenu, MdMenuItem,
MdSplitButton, MdStep, MdStepper, MdTimePicker
} from '@awc-ui/react';
const SERVICES = [
{ name: 'Initial assessment', detail: 'Full movement screen and a treatment plan.', duration: 60, price: '€70' },
{ name: 'Follow-up session', detail: 'Continue an existing treatment plan.', duration: 30, price: '€45' },
{ name: 'Sports massage', detail: 'Deep-tissue work, pre- or post-event.', duration: 45, price: '€55' }
];
// Function property on md-date-picker — the clinic closes on weekends.
const isWeekend = (d) => d.getDay() === 0 || d.getDay() === 6;
function formatDate(iso) {
const p = iso.split('-');
return new Date(+p[0], +p[1] - 1, +p[2]).toLocaleDateString('en-GB',
{ weekday: 'long', day: 'numeric', month: 'long', year: 'numeric' });
}
export default function AppointmentBooking() {
const [service, setService] = useState(null);
const [date, setDate] = useState('');
const [time, setTime] = useState('');
const [step, setStep] = useState(0);
const [confirmOpen, setConfirmOpen] = useState(false);
const [menuOpen, setMenuOpen] = useState(false);
const [status, setStatus] = useState('');
// Continue is gated per step: a service on step 1, a full slot on step 2.
const nextDisabled = step === 0 ? !service : step === 1 ? !(date && time) : false;
function downloadIcs() {
const pad = (n) => (n < 10 ? '0' : '') + n;
const stamp = (dt) => '' + dt.getFullYear() + pad(dt.getMonth() + 1) +
pad(dt.getDate()) + 'T' + pad(dt.getHours()) + pad(dt.getMinutes()) + '00';
const p = date.split('-');
const t = time.split(':');
const start = new Date(+p[0], +p[1] - 1, +p[2], +t[0], +t[1]);
const end = new Date(start.getTime() + service.duration * 60000);
const lines = ['BEGIN:VCALENDAR', 'VERSION:2.0', 'BEGIN:VEVENT',
'DTSTART:' + stamp(start), 'DTEND:' + stamp(end),
'SUMMARY:' + service.name + ' — Meridian Physio',
'LOCATION:Meridian Physio, 14 Arcului St, Bucharest',
'END:VEVENT', 'END:VCALENDAR'];
const a = document.createElement('a');
a.href = 'data:text/calendar;charset=utf-8,' + encodeURIComponent(lines.join('\r\n'));
a.download = 'meridian-appointment.ics';
a.click();
setStatus('meridian-appointment.ics saved — open it to add the event.');
}
function copyDetails() {
navigator.clipboard.writeText(service.name + ' — ' + formatDate(date) + ' ' +
time + ', Meridian Physio, 14 Arcului St, Bucharest');
setStatus('Details copied to the clipboard.');
}
return (
<div style={{ maxWidth: 680, marginInline: 'auto' }}>
<MdStepper label="Booking progress" finishLabel="Book appointment"
nextDisabled={nextDisabled}
onMdStepChange={(e) => setStep(e.detail.index)}
onMdComplete={() => setConfirmOpen(true)}>
<MdStep label="Service" description="What do you need?">
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(180px, 1fr))', gap: 12 }}>
{SERVICES.map((s) => (
<MdCard key={s.name} interactive
variant={service && service.name === s.name ? 'filled' : 'outlined'}
onMdClick={() => setService(s)}>
<span>{s.name}</span>
<span>{s.detail}</span>
<span>{s.duration + ' min · ' + s.price}</span>
</MdCard>
))}
</div>
</MdStep>
<MdStep label="Date & time" description="Pick a free slot">
<MdDatePicker label="Date" required clearable
min="2026-09-01" max="2026-10-30" locale="en-GB" firstDayOfWeek={1}
supportingText="Monday to Friday" reserveSupportingSpace
isDateDisabled={isWeekend}
onMdChange={(e) => setDate(e.detail.value)} />
<MdTimePicker label="Time" required format="24h"
min="09:00" max="16:30" minuteStep={30}
onMdChange={(e) => setTime(e.detail.value)} />
</MdStep>
<MdStep label="Confirm" description="Review your booking">
<div style={{ display: 'grid', gridTemplateColumns: 'max-content 1fr', gap: '8px 24px' }}>
<span>Service</span><span>{service ? service.name : '—'}</span>
<span>When</span><span>{date && time ? formatDate(date) + ', ' + time : '—'}</span>
<span>Duration</span><span>{service ? service.duration + ' minutes' : '—'}</span>
<span>Price</span><span>{service ? service.price : '—'}</span>
</div>
</MdStep>
</MdStepper>
<MdDialog open={confirmOpen} headline="Appointment booked" icon="event_available"
scrimDismissible={false} onMdClose={() => setConfirmOpen(false)}>
{service && date && (
<p>{service.name + ' with Dr. Elena Rusu on ' + formatDate(date) + ' at ' + time + '.'}</p>
)}
<MdSplitButton id="cal-split" variant="tonal" icon="event" label="Add to calendar"
menuLabel="More calendar options" controls="cal-menu" trailingChecked={menuOpen}
onMdLeadingClick={downloadIcs}
onMdTrailingClick={(e) => setMenuOpen(e.detail.checked)} />
{status && <p>{status}</p>}
<MdButton slot="actions" variant="text" onMdClick={() => setConfirmOpen(false)}>Done</MdButton>
</MdDialog>
<MdMenu id="cal-menu" anchor="cal-split" placement="bottom-start"
open={menuOpen} onMdClose={() => setMenuOpen(false)}>
<MdMenuItem headline="Download .ics file" onMdClick={downloadIcs} />
<MdMenuItem headline="Copy details" onMdClick={copyDetails} />
</MdMenu>
</div>
);
}<!-- booking.component.html -->
<md-stepper label="Booking progress" finishLabel="Book appointment"
[nextDisabled]="nextDisabled"
(mdStepChange)="step = $event.detail.index" (mdComplete)="confirmOpen = true">
<md-step label="Service" description="What do you need?">
@for (s of services; track s.name) {
<md-card interactive [variant]="service === s ? 'filled' : 'outlined'"
(mdClick)="service = s">
<span>{{ s.name }}</span>
<span>{{ s.detail }}</span>
<span>{{ s.duration }} min · {{ s.price }}</span>
</md-card>
}
</md-step>
<md-step label="Date & time" description="Pick a free slot">
<md-date-picker label="Date" required clearable min="2026-09-01" max="2026-10-30"
locale="en-GB" [firstDayOfWeek]="1" supportingText="Monday to Friday"
[reserveSupportingSpace]="true" [isDateDisabled]="isWeekend"
(mdChange)="date = $event.detail.value"></md-date-picker>
<md-time-picker label="Time" required format="24h" min="09:00" max="16:30"
[minuteStep]="30" (mdChange)="time = $event.detail.value"></md-time-picker>
</md-step>
<md-step label="Confirm" description="Review your booking">
<span>Service</span><span>{{ service ? service.name : '—' }}</span>
<span>When</span><span>{{ date && time ? formatDate(date) + ', ' + time : '—' }}</span>
<span>Duration</span><span>{{ service ? service.duration + ' minutes' : '—' }}</span>
<span>Price</span><span>{{ service ? service.price : '—' }}</span>
</md-step>
</md-stepper>
<md-dialog [open]="confirmOpen" headline="Appointment booked" icon="event_available"
[scrimDismissible]="false" (mdClose)="confirmOpen = false">
@if (service && date) {
<p>{{ service.name }} with Dr. Elena Rusu on {{ formatDate(date) }} at {{ time }}.</p>
}
<md-split-button id="cal-split" variant="tonal" icon="event" label="Add to calendar"
menuLabel="More calendar options" controls="cal-menu" [trailingChecked]="menuOpen"
(mdLeadingClick)="downloadIcs()"
(mdTrailingClick)="menuOpen = $event.detail.checked"></md-split-button>
@if (status) { <p>{{ status }}</p> }
<md-button slot="actions" variant="text" (mdClick)="confirmOpen = false">Done</md-button>
</md-dialog>
<md-menu id="cal-menu" anchor="cal-split" placement="bottom-start"
[open]="menuOpen" (mdClose)="menuOpen = false">
<md-menu-item headline="Download .ics file" (mdClick)="downloadIcs()"></md-menu-item>
<md-menu-item headline="Copy details" (mdClick)="copyDetails()"></md-menu-item>
</md-menu>
// booking.component.ts
import { Component } from '@angular/core';
import {
MdButton, MdCard, MdDatePicker, MdDialog, MdMenu, MdMenuItem,
MdSplitButton, MdStep, MdStepper, MdTimePicker
} from '@awc-ui/angular';
interface Service { name: string; detail: string; duration: number; price: string; }
@Component({
selector: 'app-booking',
standalone: true,
imports: [MdStepper, MdStep, MdCard, MdDatePicker, MdTimePicker,
MdDialog, MdSplitButton, MdMenu, MdMenuItem, MdButton],
templateUrl: './booking.component.html',
})
export class BookingComponent {
services: Service[] = [
{ name: 'Initial assessment', detail: 'Full movement screen and a treatment plan.', duration: 60, price: '€70' },
{ name: 'Follow-up session', detail: 'Continue an existing treatment plan.', duration: 30, price: '€45' },
{ name: 'Sports massage', detail: 'Deep-tissue work, pre- or post-event.', duration: 45, price: '€55' },
];
service?: Service;
date = ''; time = ''; step = 0;
confirmOpen = false; menuOpen = false; status = '';
// Function property on md-date-picker — the clinic closes on weekends.
isWeekend = (d: Date) => d.getDay() === 0 || d.getDay() === 6;
// Continue is gated per step: a service on step 1, a full slot on step 2.
get nextDisabled(): boolean {
if (this.step === 0) return !this.service;
if (this.step === 1) return !(this.date && this.time);
return false;
}
formatDate(iso: string): string {
const p = iso.split('-');
return new Date(+p[0], +p[1] - 1, +p[2]).toLocaleDateString('en-GB',
{ weekday: 'long', day: 'numeric', month: 'long', year: 'numeric' });
}
downloadIcs(): void {
const pad = (n: number) => (n < 10 ? '0' : '') + n;
const stamp = (dt: Date) => '' + dt.getFullYear() + pad(dt.getMonth() + 1) +
pad(dt.getDate()) + 'T' + pad(dt.getHours()) + pad(dt.getMinutes()) + '00';
const p = this.date.split('-');
const t = this.time.split(':');
const start = new Date(+p[0], +p[1] - 1, +p[2], +t[0], +t[1]);
const end = new Date(start.getTime() + (this.service?.duration ?? 0) * 60000);
const lines = ['BEGIN:VCALENDAR', 'VERSION:2.0', 'BEGIN:VEVENT',
'DTSTART:' + stamp(start), 'DTEND:' + stamp(end),
'SUMMARY:' + this.service?.name + ' — Meridian Physio',
'LOCATION:Meridian Physio, 14 Arcului St, Bucharest',
'END:VEVENT', 'END:VCALENDAR'];
const a = document.createElement('a');
a.href = 'data:text/calendar;charset=utf-8,' + encodeURIComponent(lines.join('\r\n'));
a.download = 'meridian-appointment.ics';
a.click();
this.status = 'meridian-appointment.ics saved — open it to add the event.';
}
copyDetails(): void {
navigator.clipboard.writeText(this.service?.name + ' — ' + this.formatDate(this.date) +
' ' + this.time + ', Meridian Physio, 14 Arcului St, Bucharest');
this.status = 'Details copied to the clipboard.';
}
}<script setup lang="ts">
import { computed, ref } from 'vue';
import {
MdButton, MdCard, MdDatePicker, MdDialog, MdMenu, MdMenuItem,
MdSplitButton, MdStep, MdStepper, MdTimePicker
} from '@awc-ui/vue';
interface Service { name: string; detail: string; duration: number; price: string; }
const services: Service[] = [
{ name: 'Initial assessment', detail: 'Full movement screen and a treatment plan.', duration: 60, price: '€70' },
{ name: 'Follow-up session', detail: 'Continue an existing treatment plan.', duration: 30, price: '€45' },
{ name: 'Sports massage', detail: 'Deep-tissue work, pre- or post-event.', duration: 45, price: '€55' }
];
const service = ref<Service | null>(null);
const date = ref('');
const time = ref('');
const step = ref(0);
const confirmOpen = ref(false);
const menuOpen = ref(false);
const status = ref('');
// Function property on md-date-picker — the clinic closes on weekends.
const isWeekend = (d: Date) => d.getDay() === 0 || d.getDay() === 6;
// Continue is gated per step: a service on step 1, a full slot on step 2.
const nextDisabled = computed(() =>
step.value === 0 ? !service.value :
step.value === 1 ? !(date.value && time.value) : false);
function formatDate(iso: string) {
const p = iso.split('-');
return new Date(+p[0], +p[1] - 1, +p[2]).toLocaleDateString('en-GB',
{ weekday: 'long', day: 'numeric', month: 'long', year: 'numeric' });
}
function downloadIcs() {
const pad = (n: number) => (n < 10 ? '0' : '') + n;
const stamp = (dt: Date) => '' + dt.getFullYear() + pad(dt.getMonth() + 1) +
pad(dt.getDate()) + 'T' + pad(dt.getHours()) + pad(dt.getMinutes()) + '00';
const p = date.value.split('-');
const t = time.value.split(':');
const start = new Date(+p[0], +p[1] - 1, +p[2], +t[0], +t[1]);
const end = new Date(start.getTime() + (service.value?.duration ?? 0) * 60000);
const lines = ['BEGIN:VCALENDAR', 'VERSION:2.0', 'BEGIN:VEVENT',
'DTSTART:' + stamp(start), 'DTEND:' + stamp(end),
'SUMMARY:' + service.value?.name + ' — Meridian Physio',
'LOCATION:Meridian Physio, 14 Arcului St, Bucharest',
'END:VEVENT', 'END:VCALENDAR'];
const a = document.createElement('a');
a.href = 'data:text/calendar;charset=utf-8,' + encodeURIComponent(lines.join('\r\n'));
a.download = 'meridian-appointment.ics';
a.click();
status.value = 'meridian-appointment.ics saved — open it to add the event.';
}
function copyDetails() {
navigator.clipboard.writeText(service.value?.name + ' — ' + formatDate(date.value) +
' ' + time.value + ', Meridian Physio, 14 Arcului St, Bucharest');
status.value = 'Details copied to the clipboard.';
}
</script>
<template>
<MdStepper label="Booking progress" finish-label="Book appointment"
:next-disabled="nextDisabled"
@md-step-change="step = $event.detail.index" @md-complete="confirmOpen = true">
<MdStep label="Service" description="What do you need?">
<MdCard v-for="s in services" :key="s.name" interactive
:variant="service && service.name === s.name ? 'filled' : 'outlined'"
@md-click="service = s">
<span>{{ s.name }}</span>
<span>{{ s.detail }}</span>
<span>{{ s.duration }} min · {{ s.price }}</span>
</MdCard>
</MdStep>
<MdStep label="Date & time" description="Pick a free slot">
<MdDatePicker label="Date" required clearable min="2026-09-01" max="2026-10-30"
locale="en-GB" :first-day-of-week="1" supporting-text="Monday to Friday"
reserve-supporting-space :is-date-disabled="isWeekend"
@md-change="date = $event.detail.value" />
<MdTimePicker label="Time" required format="24h" min="09:00" max="16:30"
:minute-step="30" @md-change="time = $event.detail.value" />
</MdStep>
<MdStep label="Confirm" description="Review your booking">
<span>Service</span><span>{{ service ? service.name : '—' }}</span>
<span>When</span><span>{{ date && time ? formatDate(date) + ', ' + time : '—' }}</span>
<span>Duration</span><span>{{ service ? service.duration + ' minutes' : '—' }}</span>
<span>Price</span><span>{{ service ? service.price : '—' }}</span>
</MdStep>
</MdStepper>
<MdDialog :open="confirmOpen" headline="Appointment booked" icon="event_available"
:scrim-dismissible="false" @md-close="confirmOpen = false">
<p v-if="service && date">
{{ service.name }} with Dr. Elena Rusu on {{ formatDate(date) }} at {{ time }}.
</p>
<MdSplitButton id="cal-split" variant="tonal" icon="event" label="Add to calendar"
menu-label="More calendar options" controls="cal-menu" :trailing-checked="menuOpen"
@md-leading-click="downloadIcs" @md-trailing-click="menuOpen = $event.detail.checked" />
<p v-if="status">{{ status }}</p>
<MdButton slot="actions" variant="text" @md-click="confirmOpen = false">Done</MdButton>
</MdDialog>
<MdMenu id="cal-menu" anchor="cal-split" placement="bottom-start"
:open="menuOpen" @md-close="menuOpen = false">
<MdMenuItem headline="Download .ics file" @md-click="downloadIcs" />
<MdMenuItem headline="Copy details" @md-click="copyDetails" />
</MdMenu>
</template><script>
import { onMount } from 'svelte';
// Registers every md-* element on the client. In SvelteKit, onMount already
// guarantees the browser — plain SPAs can call it at module level instead.
import { defineCustomElements } from '@awc-ui/svelte';
const services = [
{ name: 'Initial assessment', detail: 'Full movement screen and a treatment plan.', duration: 60, price: '€70' },
{ name: 'Follow-up session', detail: 'Continue an existing treatment plan.', duration: 30, price: '€45' },
{ name: 'Sports massage', detail: 'Deep-tissue work, pre- or post-event.', duration: 45, price: '€55' }
];
let service = null;
let date = '';
let time = '';
let step = 0;
let status = '';
let stepperEl;
let datePickerEl;
let dialogEl;
let menuEl;
let splitEl;
// Continue is gated per step: a service on step 1, a full slot on step 2.
$: nextDisabled = step === 0 ? !service : step === 1 ? !(date && time) : false;
$: if (stepperEl) stepperEl.nextDisabled = nextDisabled;
onMount(() => {
defineCustomElements(window);
// isDateDisabled is a function property — assign it, there is no attribute.
datePickerEl.isDateDisabled = (d) => d.getDay() === 0 || d.getDay() === 6;
});
function formatDate(iso) {
const p = iso.split('-');
return new Date(+p[0], +p[1] - 1, +p[2]).toLocaleDateString('en-GB',
{ weekday: 'long', day: 'numeric', month: 'long', year: 'numeric' });
}
function downloadIcs() {
const pad = (n) => (n < 10 ? '0' : '') + n;
const stamp = (dt) => '' + dt.getFullYear() + pad(dt.getMonth() + 1) +
pad(dt.getDate()) + 'T' + pad(dt.getHours()) + pad(dt.getMinutes()) + '00';
const p = date.split('-');
const t = time.split(':');
const start = new Date(+p[0], +p[1] - 1, +p[2], +t[0], +t[1]);
const end = new Date(start.getTime() + service.duration * 60000);
const lines = ['BEGIN:VCALENDAR', 'VERSION:2.0', 'BEGIN:VEVENT',
'DTSTART:' + stamp(start), 'DTEND:' + stamp(end),
'SUMMARY:' + service.name + ' — Meridian Physio',
'LOCATION:Meridian Physio, 14 Arcului St, Bucharest',
'END:VEVENT', 'END:VCALENDAR'];
const a = document.createElement('a');
a.href = 'data:text/calendar;charset=utf-8,' + encodeURIComponent(lines.join('\r\n'));
a.download = 'meridian-appointment.ics';
a.click();
status = 'meridian-appointment.ics saved — open it to add the event.';
}
function copyDetails() {
navigator.clipboard.writeText(service.name + ' — ' + formatDate(date) + ' ' +
time + ', Meridian Physio, 14 Arcului St, Bucharest');
status = 'Details copied to the clipboard.';
}
</script>
<md-stepper bind:this={stepperEl} label="Booking progress" finish-label="Book appointment"
on:mdStepChange={(e) => (step = e.detail.index)}
on:mdComplete={() => (dialogEl.open = true)}>
<md-step label="Service" description="What do you need?">
{#each services as s (s.name)}
<md-card interactive variant={service === s ? 'filled' : 'outlined'}
on:mdClick={() => (service = s)}>
<span>{s.name}</span>
<span>{s.detail}</span>
<span>{s.duration} min · {s.price}</span>
</md-card>
{/each}
</md-step>
<md-step label="Date & time" description="Pick a free slot">
<md-date-picker bind:this={datePickerEl} label="Date" required clearable
min="2026-09-01" max="2026-10-30" locale="en-GB" first-day-of-week="1"
supporting-text="Monday to Friday" reserve-supporting-space
on:mdChange={(e) => (date = e.detail.value)}></md-date-picker>
<md-time-picker label="Time" required format="24h" min="09:00" max="16:30"
minute-step="30" on:mdChange={(e) => (time = e.detail.value)}></md-time-picker>
</md-step>
<md-step label="Confirm" description="Review your booking">
<span>Service</span><span>{service ? service.name : '—'}</span>
<span>When</span><span>{date && time ? formatDate(date) + ', ' + time : '—'}</span>
<span>Duration</span><span>{service ? service.duration + ' minutes' : '—'}</span>
<span>Price</span><span>{service ? service.price : '—'}</span>
</md-step>
</md-stepper>
<md-dialog bind:this={dialogEl} headline="Appointment booked" icon="event_available"
scrim-dismissible="false">
{#if service && date}
<p>{service.name} with Dr. Elena Rusu on {formatDate(date)} at {time}.</p>
{/if}
<md-split-button bind:this={splitEl} id="cal-split" variant="tonal" icon="event"
label="Add to calendar" menu-label="More calendar options" controls="cal-menu"
on:mdLeadingClick={downloadIcs}
on:mdTrailingClick={(e) => (e.detail.checked ? menuEl.show() : menuEl.close())}></md-split-button>
{#if status}<p>{status}</p>{/if}
<md-button slot="actions" variant="text" on:mdClick={() => (dialogEl.open = false)}>Done</md-button>
</md-dialog>
<md-menu bind:this={menuEl} id="cal-menu" anchor="cal-split" placement="bottom-start"
on:mdClose={() => (splitEl.trailingChecked = false)}>
<md-menu-item headline="Download .ics file" on:mdClick={downloadIcs}></md-menu-item>
<md-menu-item headline="Copy details" on:mdClick={copyDetails}></md-menu-item>
</md-menu>How it’s built
Section titled “How it’s built”| Component | Role in this screen |
|---|---|
md-stepper + md-step | The spine. mode="linear" (the default) blocks jumping ahead via step headers, next-disabled gates the built-in Continue until the step’s data exists, finish-label turns the last Continue into “Book appointment”, and mdComplete opens the confirmation dialog. Steps are direct children — a wrapper <div> would make the stepper see zero steps. |
md-card | Service tiles. interactive with no focusable children gives each card role="button", tabindex="0" and Enter/Space activation for free; selection toggles variant from outlined to filled. |
md-date-picker | The slot’s day. min/max bound the calendar to the Sep–Oct window and the isDateDisabled property (there is no attribute) greys out weekends. value and mdChange.detail.value are always ISO YYYY-MM-DD, whatever locale displays. |
md-time-picker | The slot’s time. min="09:00" max="16:30" match opening hours, minute-step="30" snaps the dial to real slot granularity, and value is always a 24-hour HH:MM string. |
md-dialog | The booked confirmation. scrim-dismissible="false" so a stray click cannot dismiss it; the slotted Done button is wired to close it (slotted actions never close automatically). |
md-split-button + md-menu | “Add to calendar” is the default action (it saves a genuine .ics file); the trailing chevron opens a menu of variations. controls="cal-menu" wires aria-controls, and the menu’s mdClose resets trailing-checked so the chevron never lies. |
The details that make it production-grade
Section titled “The details that make it production-grade”- Impossible slots cannot be picked, only possible ones confirmed.
min/maxon both pickers plus the weekend predicate mean the server never has to reject a date — and disabled day cells are exposed to assistive tech asdisabled, not merely greyed out. - Canonical value formats. The date picker emits ISO
YYYY-MM-DDand the time picker 24-hourHH:MMregardless of displaylocaleorformat. Both are form-associated viaElementInternals: give them anameinside a<form>and they submit like native inputs — no hidden fields. - The gating is layered, not decorative.
next-disabledonly disables the built-in Continue;mode="linear"is what blocks step-header jumps. The stepper also announces progress (“Step 2 of 3: Date & time, current”) through a built-in polite live region. - The dialog handles focus. On open it focuses the first tabbable element — the trap descends into slotted shadow roots, so the split button counts — wraps Tab inside, and returns focus to the trigger on close.
- The split button’s accessible names are explicit. The leading segment is
named by
label; the icon-only trailing toggle is named only bymenu-label(WCAG 4.1.2), andaria-expandedmirrorstrailing-checked.
Variations
Section titled “Variations”- One-click date picking: add
commit-on-selectto the date picker and a day click commits without the Cancel/OK row. Keep the confirm step for bookings — the readme’s rule is to drop it only on low-stakes, reversible fields like filters. - US audience: set
format="12h"on the time picker. Only the display changes —valuestays"14:30", so no parsing code moves. - Narrow screens: switch the stepper to
variant="mobile"and move the active panel into itscontentslot, swapped onmdStepChange— the mobile bar hides the step-header row entirely. - Server-driven availability: when free slots are sparse, replace the time
picker with an
md-selectof the actual openings — the time picker’s own guidance says a few fixed slots belong in a select, not on a dial.
Related
Section titled “Related”- Recipe: Two-factor verification — another gated flow: OTP entry with resend countdown and a dialog fallback.
- Component manuals:
md-date-pickerandmd-time-picker— value formats, validation templates, and the full keyboard maps;md-stepper— themdBeforeChangevalidation hook this demo’s simpler gating sits on.