Skip to content

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
Initial assessment Full movement screen and a treatment plan. 60 min · €70 Follow-up session Continue an existing treatment plan. 30 min · €45 Sports massage Deep-tissue work, pre- or post-event. 45 min · €55
Service When Duration Price WhereMeridian Physio, 14 Arcului St, Bucharest

Done
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 &amp; 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>
ComponentRole in this screen
md-stepper + md-stepThe 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-cardService 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-pickerThe 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-pickerThe 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-dialogThe 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.
  • Impossible slots cannot be picked, only possible ones confirmed. min/max on both pickers plus the weekend predicate mean the server never has to reject a date — and disabled day cells are exposed to assistive tech as disabled, not merely greyed out.
  • Canonical value formats. The date picker emits ISO YYYY-MM-DD and the time picker 24-hour HH:MM regardless of display locale or format. Both are form-associated via ElementInternals: give them a name inside a <form> and they submit like native inputs — no hidden fields.
  • The gating is layered, not decorative. next-disabled only 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 by menu-label (WCAG 4.1.2), and aria-expanded mirrors trailing-checked.
  • One-click date picking: add commit-on-select to 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 — value stays "14:30", so no parsing code moves.
  • Narrow screens: switch the stepper to variant="mobile" and move the active panel into its content slot, swapped on mdStepChange — the mobile bar hides the step-header row entirely.
  • Server-driven availability: when free slots are sparse, replace the time picker with an md-select of the actual openings — the time picker’s own guidance says a few fixed slots belong in a select, not on a dial.
  • Recipe: Two-factor verification — another gated flow: OTP entry with resend countdown and a dialog fallback.
  • Component manuals: md-date-picker and md-time-picker — value formats, validation templates, and the full keyboard maps; md-stepper — the mdBeforeChange validation hook this demo’s simpler gating sits on.