Skip to content

πŸš€ Light, unstyled and flexible library to quickly create booking forms with location search (debounce), calendar, and guest fields.

License

Notifications You must be signed in to change notification settings

max-sym/react-booking-form

Repository files navigation

npm GitHub last commit npm bundle size npm bundle size GitHub

React Booking Form πŸ”₯

Lightweight, flexible library for quickly creating booking forms for websites with purposes such as hotel, restaurant, transport services booking and similar.
Includes location, calendar, and guest selectors logic (style agnostic) and much more.

Live examples

Installation

npm: npm i react-booking-form
yarn: yarn add react-booking-form

Features included

Main:

βœ… Style agnostic: style your components however you want. Logic is taken care of by the library
βœ… Debounced location search
βœ… Swap locations
βœ… Focus on the next form fields right after selections
βœ… Date calendar with time selector flatpickr
βœ… Ability to set check-out minimum date depending on the selected check-in date value.
βœ… Allows human readable datetime format + localization (configuration options provided by flatpickr)
βœ… Little size
βœ… Guest count selection by person group

Layout & DOM:

βœ… Popups' position is relative to the browser window's position and size (i.e., it would try to make the popups visible even if not enough height)
βœ… Uses React Portal under the hood 🎩
βœ… Accessible
βœ… Responsive

Notes

  • The library styling is very flexible, however the calendar component styling requires including a theme css file from flatpickr. We made it so you can fully control it by including it yourself or creating your own theme;

Usage

To quickly catch up go to the CodeSandbox Playground first.
Then read the example below + configuration docs.

πŸ‘‰ TypeScript + TailwindCSS example (with tailwind-styled-components)

Install

yarn add react-booking-form moment @react-icons/all-files tailwind-styled-components

Import the libraries:

import React from "react"
import {
  DateInput,
  FormSchema,
  GuestSelect,
  GuestOption,
  LocationSelect,
  useReactBookingForm,
  BookingForm as BookingFormType,
  LocationOption,
} from "react-booking-form"
import tw from "tailwind-styled-components"
import moment from "moment"
import "flatpickr/dist/themes/material_green.css"

Prepare some helper functions:

Here's some helpers that represent something similar to how we would fetch the city data in a real-world application for the location selector:

Create a file called cities.ts:

export const cities = [
	"New York",
	"Alabama",
	"Los Angeles",
	// ... Add more cities if you want 
]

Then import cities and add some helper functions:

import { cities } from "./cities"

// This is mocking a call to API that would return location search results
// whenever user types into the location input field.
const searchPlace = async (query: string) =>
  new Promise((resolve, _reject) => {
    setTimeout(() => resolve(filterAndMapCiies(query)), 600)
  })

const formattedCities = cities.map((city) => ({
  value: city.toLowerCase(),
  label: city,
}))
	
// This is what might happen on the backend in real-life application: it would search for the city.
const filterAndMapCiies = (query: string) =>
  formattedCities.filter((city) => city.value.includes(query.toLowerCase()))

// This is the list of cities to be shown initially when user didn't start the search of location yet.
const defaultLocationOptions: LocationOption[] = formattedCities.slice(0, 5)

Define your form schema:

const dateConfig = {
  altInput: true,
  altFormat: "M j, Y",
  dateFormat: "Y-m-d",
  wrap: true,
}

const formSchema: FormSchema = {
  from: {
    type: "location",
    options: { defaultLocationOptions, searchPlace },
    focusOnNext: "to",
  },
  to: {
    type: "location",
    options: { defaultLocationOptions, searchPlace },
    focusOnNext: "checkIn",
  },
  checkIn: {
    type: "date",
    focusOnNext: "checkOut",
    options: { ...dateConfig, minDate: "today" },
  },
  checkOut: {
    type: "date",
    focusOnNext: "guests",
    options: { ...dateConfig, minDateFrom: "checkIn" },
  },
  guests: {
    type: "peopleCount",
    defaultValue: [
      {
        name: "adults",
        label: "Adults",
        description: "Ages 13+",
        value: 1,
        min: 0,
        max: 10,
      },
      {
        name: "children",
        label: "Children",
        description: "Ages 4-12",
        value: 0,
        min: 0,
        max: 10,
      },
      {
        name: "infants",
        label: "Infants",
        description: "Under 4 years old",
        value: 0,
        min: 0,
        max: 10,
      },
    ],
  },
}

Create your booking form JSX

The form would include some of the components that we imported previously from the react-booking-form and your own components:

export const BookingForm = () => {
  const form = useReactBookingForm({ formSchema })

  const onBookButtonClick = () => {
    const config = {
      convertDate: (dateValue: Date) => moment(dateValue).format("DD-MM-YYYY"),
    }
    alert(form.serializeToURLParams(config))
  }

  return (
    <Container>
      <InputContainer>
        <Label>{"From"}</Label>
        <LocationSelect
          form={form}
          menu={Menu}
          menuContainer={MenuContainer}
          option={OptionContainer}
          inputComponent={InputComponent}
          name="from"
          emptyOption="Nothing was found :("
          placeholder="Where are you going?"
        />
      </InputContainer>
      <InputContainer style={{ width: "auto" }}>
        <SwapButton
          title="Swap Locations"
          aria-label="Swap Locations"
          onClick={() => form.swapLocations()}
        >
          <IoMdSwap className="w-4 h-4" />
        </SwapButton>
      </InputContainer>
      <InputContainer>
        <Label>{"To"}</Label>
        <LocationSelect
          form={form}
          menu={Menu}
          menuContainer={MenuContainer}
          option={OptionContainer}
          inputComponent={InputComponent}
          name="to"
          emptyOption="Nothing was found :("
          placeholder="Where are you going?"
        />
      </InputContainer>
      <InputContainer>
        <Label>{"Check in"}</Label>
        <DateInput
          inputComponent={InputComponent}
          className="w-full"
          placeholder="Add date"
          form={form}
          name="checkIn"
        />
      </InputContainer>
      <InputContainer>
        <Label>{"Check out"}</Label>
        <DateInput
          inputComponent={InputComponent}
          className="w-full"
          placeholder="Add date"
          form={form}
          name="checkOut"
        />
      </InputContainer>
      <InputContainer>
        <Label>{"Guests"}</Label>
        <GuestSelect
          form={form}
          menuContainer={MenuContainer}
          menu={Menu}
          inputComponent={InputComponent}
          option={GuestOptionComponent}
          okButton={GuestOkButton}
          okText="Ok!"
          placeholder="Add guests"
          name={"guests"}
        />
      </InputContainer>
      <InputContainer>
        <SearchButton onClick={onBookButtonClick}>{"Search"}</SearchButton>
      </InputContainer>
    </Container>
  )
}

Styling Components

Below are our own custom components (styled by tailwind-styled-components, but you can use anything for styling).

Import the icons right away:

import { FaMapMarkerAlt } from "@react-icons/all-files/fa/FaMapMarkerAlt"
import { FaCalendarAlt } from "@react-icons/all-files/fa/FaCalendarAlt"
import { FaSpinner } from "@react-icons/all-files/fa/FaSpinner"
import { FaPlus } from "@react-icons/all-files/fa/FaPlus"
import { FaMinus } from "@react-icons/all-files/fa/FaMinus"
import { FaUser } from "@react-icons/all-files/fa/FaUser"
import { IoMdSwap } from "@react-icons/all-files/io/IoMdSwap"

Now start styling with tw from tailwind-styled-components package.

const Container = tw.div`md:rounded-full rounded-xl bg-white p-6 shadow-xl flex justify-between flex-col md:flex-row md:space-x-2 md:space-y-0 space-y-2 border border-gray-300`
const InputContainer = tw.div`relative w-full md:w-1/3 flex flex-col justify-center items-center pl-2`
const InputCore = tw.input`relative w-full peer flex h-10 focus:outline-none appearance-none border border-gray-300 rounded-full outline-none transition pl-4 pr-6 group-hover:border-green-500 focus:border-green-500 cursor-pointer`
const Label = tw.div`text-sm w-full font-bold mb-1 text-gray-500`

const ButtonCore = tw.button`appearance-none h-10 rounded-full flex justify-center items-center font-bold px-3`
const SwapButton = tw(ButtonCore)`
md:mt-5 border md:w-full border-gray-300 hover:border-green-500 hover:text-green-500 focus:border-green-500 focus:text-green-500 transition outline-none`
const PrimaryButton = tw(ButtonCore)`
border-0 bg-green-500 text-white uppercase`
const GuestOkButton = tw(PrimaryButton)`mx-auto w-5/6 mb-2`
const SearchButton = tw(PrimaryButton)`w-full mt-5`

const IconContainer = tw.a`z-20 absolute top-0 right-0 bottom-0 h-full flex items-center pr-2 cursor-pointer group-hover:text-green-500 peer-focus:text-green-500 text-gray-500 transition`

const MenuContainer = tw.div`z-20`
const Menu = tw.ul<{ open: boolean }>`
  w-64 max-h-[240px] border z-20 shadow-lg transform transition ease-in-out bg-white rounded-3xl overflow-y-auto overflow-x-hidden
  ${({ open }) => (open ? "" : "opacity-0 -translate-y-4 pointer-events-none")}
`
const Text = tw.p`text-sm font-bold text-gray-700 font-title`
const SmallText = tw.p`text-sm text-gray-500`

const OptionBase = tw.div`transition ease-in-out relative py-2 px-4`
const OptionContainer = tw(OptionBase)<{
  $active?: boolean
  $selected?: boolean
}>`cursor-pointer transition ${({ $active, $selected }) =>
  $active || $selected ? "bg-green-100" : ""}`

const GuestButton = tw.button`appearance-none rounded-full p-2 flex items-center justify-center h-full overflow-hidden border border-gray-500 text-gray-500 hover:text-white hover:bg-green-500 hover:border-transparent transition ease-in-out disabled:opacity-50`

And finally add 2 more components.

The first one would be InputComponent used as an input in the main form:

type InputProps = {
  form?: BookingFormType
  isLoading?: boolean
  name?: string
  containerRef?: React.RefObject<HTMLDivElement>
}

const iconsList = {
  location: FaMapMarkerAlt,
  date: FaCalendarAlt,
  peopleCount: FaUser,
}

const InputComponent = React.forwardRef<HTMLInputElement, InputProps>(
  ({ isLoading, containerRef, form, name, ...props }, ref) => {
    const itemType = name && form?.formSchema[name].type
    const InputIcon = isLoading ? FaSpinner : iconsList[itemType || "location"]

    return (
      <div className="relative w-full group" ref={containerRef}>
        <InputCore data-input ref={ref} name={name} {...props} />
        <IconContainer title="toggle" data-toggle>
          <InputIcon className={`w-4 h-4 ${isLoading ? "animate-spin" : ""}`} />
        </IconContainer>
      </div>
    )
  }
)

And GuestOptionComponent to be used in our guest selector menu:

const GuestOptionComponent = ({
  form,
  name,
  option,
}: {
  form: BookingFormType
  name: string
  option: GuestOption
}) => (
  <OptionBase className="flex items-center justify-between">
    <div>
      <Text>{option.label}</Text>
      <SmallText>{option.description}</SmallText>
    </div>
    <div className="flex items-center justify-center gap-x-2">
      <GuestButton
        onClick={form.onPlusClick(option, name)}
        disabled={form.getIsOptionDisabled(option, "plus")}
      >
        <FaPlus className="w-3 h-3" />
      </GuestButton>
      <Text>{option.value}</Text>
      <GuestButton
        onClick={form.onMinusClick(option, name)}
        disabled={form.getIsOptionDisabled(option, "minus")}
      >
        <FaMinus className="w-3 h-3" />
      </GuestButton>
    </div>
  </OptionBase>
)
πŸ‘‰ Documentation

Basic

Here's the form schema object. This is the main library configuration:

const formSchema: FormSchema = {
  location: {
    type: "location",
    focusOnNext: "checkIn",
    options: { defaultLocationOptions, searchPlace },
  },
  checkIn: {
    type: "date",
    focusOnNext: "checkOut",
    options: {
	// These are entirely flatpickr options
      altInput: true,
      altFormat: "M j, Y",
      dateFormat: "Y-m-d",
      minDate: "today",
      wrap: true,
    },
  },
  checkOut: {
    type: "date",
    focusOnNext: "guests",
    options: {
	// These are entirely flatpickr options
      minDateFrom: "checkIn",
      altInput: true,
      altFormat: "M j, Y",
      dateFormat: "Y-m-d",
      wrap: true,
    },
  },
  guests: {
    type: "peopleCount",
    defaultValue: [
      {
        name: "adults",
        label: "Adults",
        description: "Ages 13+",
        value: 1,
        min: 0,
        max: 10,
      },
      {
        name: "children",
        label: "Children",
        description: "Ages 4-12",
        value: 0,
        min: 0,
        max: 10,
      },
      {
        name: "infants",
        label: "Infants",
        description: "Under 4 years old",
        value: 0,
        min: 0,
        max: 10,
      },
    ],
  },
}

It should be used by useReactBookingForm as:

const form = useReactBookingForm({ formSchema })

And later the form can be passed down to the BookingForm as:

export const BookingForm = () => {
  const form = useReactBookingForm({ formSchema })
	
  const onBookButtonClick = () => {
    const config = {
      convertDate: (dateValue: Date) => moment(dateValue).format("DD-MM-YYYY"),
    }
    alert(form.serializeToURLParams(config))
  }

  return (
    <Container>
      <InputContainer>
        <Label>{"From"}</Label>
        <LocationSelect
          form={form}
          menu={Menu}
          menuContainer={MenuContainer}
          option={OptionContainer}
          inputComponent={InputComponent}
          name="from"
          emptyOption="Nothing was found :("
          placeholder="Where are you going?"
        />
      </InputContainer>
      <InputContainer style={{ width: "auto" }}>
        <SwapButton
          title="Swap Locations"
          aria-label="Swap Locations"
          onClick={() => form.swapLocations()}
        >
          <IoMdSwap className="w-4 h-4" />
        </SwapButton>
      </InputContainer>
      <InputContainer>
        <Label>{"To"}</Label>
        <LocationSelect
          form={form}
          menu={Menu}
          menuContainer={MenuContainer}
          option={OptionContainer}
          inputComponent={InputComponent}
          name="to"
          emptyOption="Nothing was found :("
          placeholder="Where are you going?"
        />
      </InputContainer>
      <InputContainer>
        <Label>{"Check in"}</Label>
        <DateInput
          inputComponent={InputComponent}
          className="w-full"
          placeholder="Add date"
          form={form}
          name="checkIn"
        />
      </InputContainer>
      <InputContainer>
        <Label>{"Check out"}</Label>
        <DateInput
          inputComponent={InputComponent}
          className="w-full"
          placeholder="Add date"
          form={form}
          name="checkOut"
        />
      </InputContainer>
      <InputContainer>
        <Label>{"Guests"}</Label>
        <GuestSelect
          form={form}
          menuContainer={MenuContainer}
          menu={Menu}
          inputComponent={InputComponent}
          option={GuestOptionComponent}
          okButton={GuestOkButton}
          okText="Ok!"
          placeholder="Add guests"
          name={"guests"}
        />
      </InputContainer>
      <InputContainer>
        <SearchButton onClick={onBookButtonClick}>{"Search"}</SearchButton>
      </InputContainer>
    </Container>
  )
}

Form object

The form that's returned in the example above is an object that can be used to read current form state as well as call different callbacks on form input fields and much more.

This is the form type:

export type BookingForm = {
  /**
   * Form schema provided by the user.
   */
  formSchema: FormSchema
  /**
   * Current form state.
   */
  state: FormState
  setState: (state: FormState) => void
  /**
   * Helper that sets the particular field value in the form.
   */
  setFieldValue: (key: string, value: any) => void
  /**
   * Helper that sets the particular field state in the form.
   */
  setFieldState: (key: string, state: any) => void
  /**
   * An array of references to the form fields.
   * This can be used to focus on a particular field and do other relevant actions.
   */
  refs: RefsType
  /**
   * Helper that allows to focus on a particular field just by passing field key to it.
   */
  focusOn: (key?: string) => void
  /**
   * This is a helper that allows to change a particular option item state.
   * For example, if you want to increment the number of "adults", you can use this helper as:
   * ```
   * form.setGuestOptionValue(name, option, option.value + 1)
   * ```
   */
  setGuestOptionValue: (key: string, option: any, value: any) => void
  /**
   * A callback to pass to the guest minus button click event.
   */
  onMinusClick: (option: GuestOption, name: string) => () => void
  /**
   * A callback to pass to the guest plus button click event.
   */
  onPlusClick: (option: GuestOption, name: string) => () => void
  /**
   * A callback to pass to the guest buttons to determine if the buttons are disabled.
   */
  getIsOptionDisabled: (
    option: GuestOption,
    optionType: "plus" | "minus"
  ) => boolean
  /**
   * This can be used to swap the location fields.
   */
  swapLocations: (fieldKeys?: [string, string] | undefined) => void
  /**
   * Converts the form state to url query string.
   * Use convertDate to convert dates to the desired format.
   */
  serializeToURLParams: ({
    convertDate,
  }: {
    convertDate?: (dateValue: Date) => any
  }) => string
}

Form state object

export type FormState = {
  [key: string]: {
    type: FieldType
    value: FieldValue
    /**
     * Used to know total number of guests in guest selector.
     */
    totalCount?: number
  }
}

Schema object

Name Type Required Description Example
type "location" | "date" | "peopleCount" βœ… The type of the field "location"
focusOnNext string The key of some form schema object item to focus upon after selecting this field "checkIn" (if checkIn key exists in the formSchema object)
defaultValue LocationOption | GuestOption[] | string Default value of the field. In case of the type of the form field being "location", it should be of LocationOption type. Should be an array of GuestOption's in case of "peopleCount" respectively Location example: { label: "New York", value: "new-york" } Guest Example: { value: 1, name: "adults" }
options Options Options object. Refer to its schema (defined below)

Options

Name Type Description
defaultLocationOptions LocationOption[] If the field type is "location" then it accepts these locations as default values
searchPlace (queryString:string) => Promise<any> A function that fetches the location data. Refer to the TypeScript example in the repo documentation.
[key: string] any Parameters that will be passed down to the flatpickr React component. Here's a list of options you can pass

Contribution Guide

  • Clone the repository to your local machine;
  • Run yarn in the root
  • Run yarn start
  • Go to localhost:6006 and see magic happen πŸš€

Whenever finished, create a PR on a separate branch for review.

Contributions are very welcome!

License

MIT