Accessible hotel booking widget with flatpickr.js

This is a booking component that I created toward the end of last year. It accepts an arrival date and departure date within a <form> which is then submitted to a third party booking engine via a query string URL.

I used flatpickr.js for the datepickers so that I could have more control over the way the datepicker looks and also some date handling features that flatpickr.js provides. It’s also keyboard accessible and complies with WCAG guidelines.

When the user selects an ‘Arrival date’ then the ‘Departure date’ will automatically be selected two days (this can easily be changed to another number) ahead of the user’s selected date. Or vice-versa, if the user selects a ‘Departure date’ first then the ‘Arrival date’ will be selected two days before the user’s selected date.
Users cannot select an ‘Arrival date’ in the past nor can they select the current date as the ‘Departure date’.
There is also some validation in place that will display an error message if either or both of the ‘Arrival date’ or ‘Departure date’ inputs are not selected upon submitting the form.

This particular booking component is configured to work with IHG booking engines, which has some odd requirements for submitting the month/day/year for the arrival and departure date query strings:

  • The checkInDate/checkOutDate values must not have a leading zero.
  • The checkInMonth/checkOutMonth values must be zero-based.
  • The checkInMonth/checkOutMonth and checkInYear/checkOutYear values must be combined into respective checkInMonthYear/checkOutMonthYear values.

If you’re not using an IHG booking engine then you can remove the lines after the “IHG only!” comment. The booking component will work with most booking engines out of the box, assuming that they will simply accept a ‘YYYY-MM-DD’ format for the arrival and departure query string values. If not, you may need to modify the script and the <form> inputs as needed.

CodePen

JavaScript

// Created by Alex Winter
// Last Modified: 2023-06-21




// *****************************************************
//  Booking Form
//  Flatpickr.js, selected date logic, validation, etc.
// *****************************************************
// allows for multiple booking forms by setting the "bookingId" parameter
const booking = (bookingId) => {
    // Get DOM elements
    const bookingForm = bookingId.querySelector('form');
    const bookingSubmit = bookingId.querySelector('.booking-submit');
    const arrival = bookingId.querySelector('.arrival');
    const departure = bookingId.querySelector('.departure');

    // default input placeholders
    const arrivalPlaceholder = 'Arrival';
    const departurePlaceholder = 'Departure';
    arrival.setAttribute('placeholder', arrivalPlaceholder);
    departure.setAttribute('placeholder', departurePlaceholder);

    // arrival flatpickr instance
    const arrivalFp = flatpickr(arrival, {
        minDate: 'today', // ensures user cannot select a date prior to today
        disableMobile: 'true', // disables native datepicker on mobile
        monthSelectorType: 'static', // disables 'month' dropdown from flatpickr's datepicker (hope they add yearSelectorType soon)
        altInput: true,
        altFormat: "m/d/Y",
        dateFormat: "Y-m-d",
        onReady: (a, b, fp) => {
            fp.altInput.setAttribute('aria-label', arrivalPlaceholder);
        }
    });

    // departure flatpickr instance
    const departureFp = flatpickr(departure, {
        minDate: new Date().fp_incr(1), // ensures user cannot select a date prior to tomorrow
        disableMobile: 'true', // disables native datepicker on mobile
        monthSelectorType: 'static', // disables 'month' dropdown from flatpickr's datepicker (hope they add yearSelectorType soon)
        altInput: true,
        altFormat: "m/d/Y",
        dateFormat: "Y-m-d",
        onReady: (a, b, fp) => {
            fp.altInput.setAttribute('aria-label', departurePlaceholder);
        }
    });

    // add custom classes to the arrival and departure 'flatpickr-calendar' elements
    arrivalFp.calendarContainer.classList.add('arrival-calendar');
    departureFp.calendarContainer.classList.add('departure-calendar');

    // flatpickr's date values
    let arrivalDateChosen = arrivalFp.selectedDates;
    let departureDateChosen = departureFp.selectedDates;
    // js date objects
    let arrivalDateObj = new Date(arrivalDateChosen);
    let departureDateObj = new Date(departureDateChosen);

    // update flatpickr's date values
    const updateDates = () => {
        // flatpickr date values
        arrivalDateChosen = arrivalFp.selectedDates;
        departureDateChosen = departureFp.selectedDates;
        // js date objects
        arrivalDateObj = new Date(arrivalDateChosen);
        departureDateObj = new Date(departureDateChosen);
    };

    // run 'updateDates' function every time the user selects a new arrival or departure
    [arrival, departure].forEach((item) => {
        item.addEventListener('change', () => {
            updateDates();
        });
    });

    // amount of days to add or subtract
    const defaultDaySpan = 2;

    // add days to departure date
    const addDays = () => {
        departureDateObj = new Date(arrivalDateObj);
        departureDateObj.setDate(arrivalDateObj.getDate() + defaultDaySpan)
        departureFp.setDate(departureDateObj);
    };

    // subtract days from arrival date
    const subDays = () => {
        arrivalDateObj = new Date(departureDateObj);
        arrivalDateObj.setDate(departureDateObj.getDate() - defaultDaySpan)
        arrivalFp.setDate(arrivalDateObj);
    };

    // 86400000 milliseconds = 1 day
    const minimumDistance = 86400000;
    let dayDistance = 0;
    // get the distance between days as a number of milliseconds
    const getDayDistance = () => {
        dayDistance = departureDateObj - arrivalDateObj;
    };

    // user selects arrival date
    arrival.addEventListener('change', () => {
        getDayDistance();

        // if departure date is not selected yet - or -
        // if arrival date is SAME DAY or AFTER currently selected departure date
        if (!departureDateChosen.length || (departureDateChosen.length && dayDistance < minimumDistance)) {
            addDays();
        }

        updateDates();
    });

    // user selects departure date
    departure.addEventListener('change', () => {
        getDayDistance();

        // if arrival date is not selected yet - or -
        // if departure date is SAME DAY or BEFORE currently selected arrival date
        if (!arrivalDateChosen.length || (arrivalDateChosen.length && dayDistance < minimumDistance)) {
            subDays();
        };

        updateDates();
    });

    // validate the form when the submit button is clicked
    let errorMessageVisible = false; // prevents odd behaviour if user is button mashing

    // create 'booking-error' element and get DOM elements
    document.querySelector('body').insertAdjacentHTML('beforeend', '

'); const error = document.querySelector('.booking-error'); const errorText = document.querySelector('.booking-error p'); // submit button is clicked bookingSubmit.addEventListener('click', (event) => { // show the error message for X seconds (if one isn't already shown) const errorMessage = (message) => { if (!errorMessageVisible) { errorMessageVisible = true; errorText.textContent = message; error.classList.add('show'); setTimeout(function() { errorMessageVisible = false; error.classList.remove('show'); }, 4000) }; event.preventDefault(); } // validate: errors if (arrivalDateChosen.length == 0 && departureDateChosen.length == 0) { errorMessage('Please select an arrival and departure date'); } else if (arrivalDateChosen.length == 0) { errorMessage('Please select an arrival date'); } else if (departureDateChosen.length == 0) { errorMessage('Please select a departure date'); } // validate: success else { // Google Analytics event tracking try { gtag('event', 'click', { 'event_category': 'Booking', 'event_label': 'Booking Form Submit' }); } catch(err) {} // ***************************************************** // Split dates into separate variables // Use only if needed // ***************************************************** // regular expression that looks for YYYY-MM-DD const regex = /(?\d{4})-(?\d{2})-(?\d{2})/; // match 'arrival' and 'departure' with 'regex' const regexArrival = arrival.value.match(regex); const regexDeparture = departure.value.match(regex); // split into separate variables const arrivalY = regexArrival.groups.year; const arrivalM = regexArrival.groups.month; const arrivalD = regexArrival.groups.day; const departureY = regexDeparture.groups.year; const departureM = regexDeparture.groups.month; const departureD = regexDeparture.groups.day; // testing /* console.log('arrival: ' + arrival.value); console.log('departure: ' + departure.value); console.log('arrival year: ' + arrivalY); console.log('arrival month: ' + arrivalM); console.log('arrival day: ' + arrivalD); console.log('departure year: ' + departureY); console.log('departure month: ' + departureM); console.log('departure day: ' + departureD); */ // ***************************************************** // IHG only! // Comment out or delete as needed! // ***************************************************** // IHG requires that the month value does not have a leading 0: const checkInDate = parseInt(arrivalD, 10); const checkOutDate = parseInt(departureD, 10); // IHG requires that the month value is zero-based: const checkInMonth = parseInt(arrivalM, 10) - 1; const checkOutMonth = parseInt(departureM, 10) - 1; // IHG requires the year value too of course const checkInYear = arrivalY; const checkOutYear = departureY; // IHG requires that we combine the month value and the year value together const checkInMonthYear = checkInMonth + checkInYear; const checkOutMonthYear = checkOutMonth + checkOutYear; // set the value for the 'checkInDate' input document.querySelector('input[name="checkInDate"]').setAttribute('value', checkInDate); // set the value for the 'checkOutDate' input document.querySelector('input[name="checkOutDate"]').setAttribute('value', checkOutDate); // set the value for the 'checkInMonthYear' input document.querySelector('input[name="checkInMonthYear"]').setAttribute('value', checkInMonthYear); // set the value for the 'checkInMonthYear' input document.querySelector('input[name="checkOutMonthYear"]').setAttribute('value', checkOutMonthYear); // testing /* console.log('checkInDate: ' + checkInDate); console.log('checkOutDate: ' + checkOutDate); console.log('checkInMonth: ' + checkInMonth); console.log('checkOutMonth: ' + checkOutMonth); console.log('checkInYear: ' + checkInYear); console.log('checkOutYear: ' + checkOutYear); console.log('checkInMonthYear: ' + checkInMonthYear); console.log('checkOutMonthYear: ' + checkOutMonthYear); */ // ***************************************************** // Submit the form! // ***************************************************** bookingForm.submit(); event.preventDefault(); }; }); }; /* Initiate the booking function and select the appropriate HTML elements within the page by setting a booking ID */ const booking1 = document.querySelector('#booking-1'); if (booking1) { booking(booking1) } /* // Add additional booking instances if necessary: const booking2 = document.querySelector('#booking-2'); if (booking2) { booking(booking2) } const booking3 = document.querySelector('#booking-3'); if (booking3) { booking(booking3) } // etc ... */

HTML

<div class="header-booking" id="booking-1" role="form" aria-label="Make a Reservation">
  <form class="booking" name="booking" method="get" target="_blank" action="//ichotelsgroup.com/redirect">
      <div class="booking-input date">
          <input type="date" class="arrival" name="arrivaldate" aria-label="Arrival Date">
          <i class="fa-regular fa-calendar-days"></i>
      </div>

      <div class="booking-input date">
          <input type="date" class="departure" name="departuredate" aria-label="Departure Date">
          <i class="fa-regular fa-calendar-days"></i>
      </div>

      <!-- for IHG booking engines only -->
      <input type="hidden" name="checkInDate" value="">
      <input type="hidden" name="checkOutDate" value="">
      <input type="hidden" name="checkInMonthYear" value="">
      <input type="hidden" name="checkOutMonthYear" value="">
      <input type="hidden" name="path" value="rates">
      <input type="hidden" name="brandCode" value="hi">
      <input type="hidden" name="numberOfAdults" value="1">
      <input type="hidden" name="numberOfChildren" value="0">
      <input type="hidden" name="numberOfRooms" value="1">
      <input type="hidden" name="rateCode" value="6CBARC">
      <input type="hidden" name="hotelCode" value="dislb">
      <input type="hidden" name="_PMID" value="99502222">

      <div class="booking-input">
          <button class="booking-submit">Book Now</button>
      </div>
  </form>
</div>

CSS

body {
  margin: 0;
}




// Flatpickr Theme (Optimized)
// Last Modified: 2021-12-06 by AW
// v1.0

// CSS for the following was removed:
// time, date ranges, weeks, month dropdown, rtl, IE11 and other outdated browsers




// **************************************************
//  .flatpickr-calendar
// **************************************************
.flatpickr-calendar {
  position: absolute;
  width: 100%;
  max-width: 316px;
  opacity: 0;
  display: none;
  visibility: hidden;
  padding: 0.25em;
  border: 0;
  text-align: center;
  direction: ltr;
  animation: none;
  box-sizing: border-box;
  touch-action: manipulation;
  background-color: #fff;
  box-shadow: 1px 0 0 #e6e6e6, -1px 0 0 #e6e6e6, 0 1px 0 #e6e6e6, 0 -1px 0 #e6e6e6, 0 3px 13px rgba(0,0,0,0.08);
  user-select: none;
}

.flatpickr-calendar.open {
  opacity: 1;
  max-height: 640px;
  visibility: visible;
}

.flatpickr-calendar.open {
  display: inline-block;
  z-index: 100000001;
}

.flatpickr-calendar.animate.open {
  animation: fpFadeInDown 300ms cubic-bezier(0.23, 1, 0.32, 1);
}
@keyframes fpFadeInDown {
  from {
    opacity: 0;
    transform: translate3d(0, -20px, 0);
  }
  to {
    opacity: 1;
    transform: translate3d(0, 0, 0);
  }
}

.flatpickr-calendar:focus {
  outline: 0;
}




// **************************************************
//  .flatpickr-months
// **************************************************
.flatpickr-months {
  position: relative;
}

.flatpickr-months .flatpickr-month {
  padding: 0.8em;
  position: relative;
  overflow: hidden;
  background-color: #EDEAEA;
  color: rgba(0,0,0,0.9);
  user-select: none;
  text-align: center;
}




// **************************************************
//  .flatpickr-prev-month and .flatpickr-next-month
// **************************************************
.flatpickr-months .flatpickr-prev-month,
.flatpickr-months .flatpickr-next-month {
  position: absolute;
  top: 0;
  height: 100%;
  padding: 1em;
  z-index: 3;
  text-decoration: none;
  cursor: pointer;
  color: rgba(0,0,0,0.9);
}

.flatpickr-months .flatpickr-prev-month.flatpickr-disabled,
.flatpickr-months .flatpickr-next-month.flatpickr-disabled {
  display: none;
}

.flatpickr-months .flatpickr-prev-month i,
.flatpickr-months .flatpickr-next-month i {
  position: relative;
}

.flatpickr-months .flatpickr-prev-month.flatpickr-prev-month,
.flatpickr-months .flatpickr-next-month.flatpickr-prev-month {
  left: 0;
}

.flatpickr-months .flatpickr-prev-month.flatpickr-next-month,
.flatpickr-months .flatpickr-next-month.flatpickr-next-month {
  right: 0;
}

.flatpickr-months .flatpickr-prev-month:hover svg,
.flatpickr-months .flatpickr-next-month:hover svg {
  fill: #3f9338;
}

.flatpickr-months .flatpickr-prev-month svg,
.flatpickr-months .flatpickr-next-month svg {
  width: 14px;
  height: 14px;
  vertical-align: bottom;
}

.flatpickr-months .flatpickr-prev-month svg path,
.flatpickr-months .flatpickr-next-month svg path {
  transition: fill 0.1s;
  fill: inherit;
}




// **************************************************
//  .flatpickr-current-month
// **************************************************
.flatpickr-current-month span.cur-month {
  display: inline-block;
}

.flatpickr-current-month .numInputWrapper {
  display: inline-block;
  width: 6ch;
}

.flatpickr-current-month .numInputWrapper span.arrowUp:after {
  border-bottom-color: rgba(0,0,0,0.9);
}

.flatpickr-current-month .numInputWrapper span.arrowDown:after {
  border-top-color: rgba(0,0,0,0.9);
}

.flatpickr-current-month input.cur-year {
  display: inline-block;
  margin: 0;
  background-color: transparent;
  box-sizing: border-box;
  cursor: text;
  padding: 0.5ch 0 0.5ch 0.5ch;
  border: 0;
  appearance: textfield;
}

.flatpickr-current-month input.cur-year:focus {
  outline: 0;
}

.flatpickr-current-month input.cur-year[disabled],
.flatpickr-current-month input.cur-year[disabled]:hover {
  color: rgba(0,0,0,0.5);
  background-color: transparent;
  pointer-events: none;
}




// **************************************************
//  .numInputWrapper
// **************************************************
.numInputWrapper {
  position: relative;
  height: auto;
}

.numInputWrapper input,
.numInputWrapper span {
  display: inline-block;
}

.numInputWrapper input {
  width: 100%;
}

.numInputWrapper input::-webkit-outer-spin-button,
.numInputWrapper input::-webkit-inner-spin-button {
  margin: 0;
  -webkit-appearance: none;
}

.numInputWrapper span {
  position: absolute;
  right: 0;
  width: 14px;
  padding: 0 4px 0 2px;
  height: 50%;
  line-height: 50%;
  opacity: 0;
  cursor: pointer;
  border: 1px solid rgba(57,57,57,0.15);
  box-sizing: border-box;
}

.numInputWrapper span:hover {
  background-color: rgba(0,0,0,0.1);
}

.numInputWrapper span:active {
  background-color: rgba(0,0,0,0.2);
}

.numInputWrapper span:after {
  display: block;
  content: "";
  position: absolute;
}

.numInputWrapper span.arrowUp {
  top: 0;
  border-bottom: 0;
}

.numInputWrapper span.arrowUp:after {
  border-left: 4px solid transparent;
  border-right: 4px solid transparent;
  border-bottom: 4px solid rgba(57,57,57,0.6);
  top: 26%;
}

.numInputWrapper span.arrowDown {
  top: 50%;
}

.numInputWrapper span.arrowDown:after {
  border-left: 4px solid transparent;
  border-right: 4px solid transparent;
  border-top: 4px solid rgba(57,57,57,0.6);
  top: 40%;
}

.numInputWrapper:hover {
  background-color: rgba(0,0,0,0.05);
}

.numInputWrapper:hover span {
  opacity: 1;
}




// **************************************************
//  .flatpickr-innerContainer
// **************************************************
.flatpickr-innerContainer {
  display: block;
  display: flex;
  box-sizing: border-box;
  overflow: hidden;
}




// **************************************************
//  .flatpickr-rContainer
// **************************************************
.flatpickr-rContainer {
  display: inline-block;
  padding: 0;
  box-sizing: border-box;
}




// **************************************************
//  .flatpickr-weekdays
// **************************************************
.flatpickr-weekdays {
  display: flex;
  align-items: center;
  width: 100%;
  padding: 0.9em 0;
  font-size: 0.9rem;
  background-color: transparent;
  overflow: hidden;
  text-align: center;
  user-select: none;
}

.flatpickr-weekdays .flatpickr-weekdaycontainer {
  display: flex;
  flex: 1;
}

span.flatpickr-weekday {
  cursor: default;
  background-color: transparent;
  color: #555;
  line-height: 1;
  margin: 0;
  text-align: center;
  display: block;
  flex: 1;
  font-weight: bolder;
}




// **************************************************
//  .flatpickr-days
// **************************************************
.flatpickr-days {
  position: relative;
  overflow: hidden;
  display: flex;
  align-items: flex-start;
  width: 307.875px;
  user-select: none;
}

.flatpickr-days:focus {
  outline: 0;
}

.dayContainer {
  padding: 0;
  outline: 0;
  text-align: left;
  width: 307.875px;
  min-width: 307.875px;
  max-width: 307.875px;
  box-sizing: border-box;
  display: inline-block;
  display: flex;
  flex-wrap: wrap;
  justify-content: space-around;
  transform: translate3d(0px, 0px, 0px);
  opacity: 1;
}

.dayContainer + .dayContainer {
  box-shadow: -1px 0 0 #e6e6e6;
}

.flatpickr-day {
  background-color: #EDEAEA;
  border: 1px solid transparent;
  // border-radius: 150px;
  box-sizing: border-box;
  color: #393939;
  cursor: pointer;
  font-weight: 400;
  width: 14.2857143%;
  flex-basis: 14.2857143%;
  max-width: 39px;
  height: 39px;
  line-height: 39px;
  margin: 0 0 0.25em 0;
  display: inline-block;
  position: relative;
  justify-content: center;
  text-align: center;
}

.flatpickr-day:hover,
.flatpickr-day.prevMonthDay:hover,
.flatpickr-day.nextMonthDay:hover,
.flatpickr-day:focus,
.flatpickr-day.prevMonthDay:focus,
.flatpickr-day.nextMonthDay:focus {
  cursor: pointer;
  outline: 0;
  color: #fff;
  background-color: #3f9338;
  border-color: #3f9338;
}

.flatpickr-day.today {
  border-color: #959ea9;
}

.flatpickr-day.today:hover,
.flatpickr-day.today:focus {
  border-color: #959ea9;
  background-color: #959ea9;
  color: #fff;
}

.flatpickr-day.selected,
.flatpickr-day.selected:focus,
.flatpickr-day.selected:hover,
.flatpickr-day.selected.prevMonthDay,
.flatpickr-day.selected.nextMonthDay {
  box-shadow: none;
  color: #fff;
  background-color: #3f9338;
  border-color: #3f9338;
}

.flatpickr-day.flatpickr-disabled,
.flatpickr-day.flatpickr-disabled:hover,
.flatpickr-day.prevMonthDay,
.flatpickr-day.nextMonthDay,
.flatpickr-day.notAllowed,
.flatpickr-day.notAllowed.prevMonthDay,
.flatpickr-day.notAllowed.nextMonthDay {
  color: rgba(57,57,57,0.3);
  background-color: transparent;
  border-color: transparent;
  cursor: default;
}

.flatpickr-day.flatpickr-disabled,
.flatpickr-day.flatpickr-disabled:hover {
  cursor: not-allowed;
  background-color: #F8F7F7;
  color: rgba(57,57,57,0.2);
}

.flatpickr-day.hidden {
  visibility: hidden;
}




// **************************************************
//  .flatpickr-input
// **************************************************
.flatpickr-input[readonly] {
  cursor: pointer;
}




// **************************************************
//  Booking Form
// **************************************************
.header-booking {
  margin-top: 4em;
}

.booking {
  display: flex;
  flex-direction: row;
  justify-content: center;
  gap: 1.25em;
  flex-wrap: wrap;

  @media (max-width: 1600px) {
    gap: 0.25em;
  }
}

.booking-input {
  &.date,
  &.select {
    width: 100%;
    max-width: 135px;
    display: flex;
    align-items: center;
    column-gap: 0.5em;
  }

  input[type='text'],
  input[type='date'],
  select,
  button {
    width: 100%;
    cursor: pointer;
    border-radius: 3px;
  }

  input[type='text'],
  input[type='date'],
  select {
    max-width: 105px;
    color: #525252;
    font-size: 0.9rem;
    font-weight: 400;
    padding: 0.5em;
    background-color: #fff;
    border: 1px solid c4c4c4;
    box-shadow: inset 1px 1px 3px c4c4c4;

    @media (max-width: 1600px) {
      max-width: 95px;
    }
  }
  
  input[type='text']::placeholder,
  input[type='date']::placeholder {
    color: #525252;
  }

  input[type='text']:hover,
  input[type='date']:hover,
  select:hover {
    border-color: #c9c9c9;
  }

  input[type='date']:after {
    content: '\f3cf';
    font-family: 'FA-Solid';
  }

  select {
    -webkit-appearance: none;
  }

  button {
    display: inline-block;
    padding-block: 0.7em;
    padding-inline: 2.5em;
    line-height: 1;
    background-color: #3f9338;
    border: 2px solid #3f9338;
    border-radius: 0.25em;
    color: #fff;
    font-size: 1.1rem;
    font-weight: 700;
    font-style: normal;
    text-align: center;
    text-decoration: none;
    text-transform: uppercase;
    transition: all 0.5s;
    
    @media (max-width: 1600px) {
      padding-inline: 1.5em;
    }
  }

  i {
    color: #525252;
    font-size: 1.35rem;
  }
}




// **************************************************
//  Banners
// **************************************************
%banner {
  display: block;
  width: 100%;
}

%banner-content {
  position: relative;
  padding: 0.65em 3em;
  margin: 0 auto;
  text-align: center;
}

%banner-text {
  font-size: 1.2rem;
  line-height: 1.4rem;
  font-weight: 400;
  color: #fff;
}




// **************************************************
//  Banners: Booking Error
// **************************************************
.booking-error {
  @extend %banner;
  width: 100%;
  max-width: 420px;
  margin: 0 auto;
  z-index: -1; // must always be less than 'banner' and 'header' element
  background-color: red;
  opacity: 0;
  // position: fixed;
  // inset: 4.5em 0 auto auto;
  margin-top: 1em;
  transition: all 1s;

  &.show {
    opacity: 1;
    z-index: 30000; // must always be more than 'banner' and 'header' element
  }
  
  p {
    color: #fff;
    margin-bottom: 0;
  }
}

.booking-error-content {
  @extend %banner-content;

  p {
    @extend %banner-text;
    margin: 0;
  }
}

CodePen