Tutorial: Build a restaurant reservation widget
Let's start building the reservation widget by just adding a button that opens a dialog.
For the UI we will use Headless UI, so let's install that first:
npm install @headlessui/react@latest
We can create all our components under src/components
.
First, we'll make a placeholder for the reservation widget:
import ReservationWidget from "./components/ReservationWidget/ReservationWidget";
function App() {
return (
<div className="w-80 mx-auto mt-10">
<ReservationWidget />
</div>
);
}
export default App;
Adding the "Book a table" button
Next, we can add the "Book a table" button. Using the one provided by Headless UI with the styles from their docs:
import { Button } from "@headlessui/react";
const ReservationWidget = () => {
return (
<>
<Button className="rounded bg-sky-600 py-2 px-4 text-sm text-white data-[hover]:bg-sky-500 data-[active]:bg-sky-700">
Book a table
</Button>
</>
);
};
export default ReservationWidget;
You'll notice in the example above, the css styles are just inlined with the button - this is fine for now, but as we add more buttons to the app, we don't want to keep copy-pasting the styles.
So let's create a new Button
component, specific to our application, that will just wrap the one from Headless UI:
import { Button as HeadlessButton } from "@headlessui/react";
const Button = (props) => {
return (
<HeadlessButton
className="rounded bg-sky-600 py-2 px-4 text-sm text-white data-[hover]:bg-sky-500 data-[active]:bg-sky-700"
{...props}
/>
);
};
export default Button;
I created the button under a ui
folder inside the components folder, to make it clear these are "ui" display components.
Adding the dialog
We can use the Dialog component from Headless UI using their sample styles.
For showing the backdrop, I used DialogBackdrop
, as described in the docs.
import Button from "../ui/Button";
import {
Dialog,
DialogPanel,
DialogTitle,
DialogBackdrop,
} from "@headlessui/react";
import { useState } from "react";
const ReservationWidget = () => {
const [isOpen, setIsOpen] = useState(false);
return (
<>
<Button onClick={() => setIsOpen(true)}>Book a table</Button>
<Dialog
open={isOpen}
onClose={() => setIsOpen(false)}
className="relative z-50"
>
{/* The backdrop, rendered as a fixed sibling to the panel container */}
<DialogBackdrop className="fixed inset-0 bg-black/30" />
{/* Full-screen container to center the panel */}
<div className="fixed inset-0 flex w-screen items-center justify-center p-4">
<DialogPanel className="max-w-lg space-y-4 border bg-white p-12">
<DialogTitle className="font-bold">Book a table</DialogTitle>
<p>This is where you'll add the details of your booking</p>
<div className="flex gap-4">
<Button onClick={() => setIsOpen(false)}>Book now</Button>
</div>
</DialogPanel>
</div>
</Dialog>
</>
);
};
export default ReservationWidget;
And that's it - we now have a dialog that shows up when the button is clicked:
Add form field to choose the number of persons
In the wireframe, the user can choose the number of persons by clicking a dropdown that shows a button for each number,1 up to 10 (you can't book for more than 10 persons).
We can use a Popover from Headless UI to show the floating persons selection.
To keep the logic encapsulated, we'll create a dedicated component just for the persons dropdown. It can act as a controlled component, that has a value
and emits an onChange
event.
We can use it as:
<PersonDropdown count={personCount} setCount={setPersonCount} />
The full component code is below (src/components/PersonDropdown
):
import { Popover, PopoverButton, PopoverPanel } from "@headlessui/react";
import { Button } from "@headlessui/react";
const PersonDropdown = ({ count, setCount }) => {
return (
<Popover className="relative">
<PopoverButton className="bg-sky-100 py-3 px-5 rounded w-full text-left">
<span className="italic text-xs mr-7">People</span>
<span>{count} persons</span>
</PopoverButton>
<PopoverPanel
anchor="bottom"
className="shadow-xl rounded box-content p-5 bg-slate-100 mt-2 w-80 flex justify-center"
>
{({ close }) => (
<div className="grid grid-cols-5 gap-4">
{[...Array(10).keys()].map((countOption) => (
<Button
key={countOption}
onClick={() => {
setCount(countOption + 1);
close();
}}
className="rounded bg-slate-50 border-slate-300 border-2 py-2 px-4 text-sm data-[hover]:border-sky-500 data-[hover]:text-sky-500 data-[active]:border-sky-700 shadow-sm"
>
{countOption + 1}
</Button>
))}
</div>
)}
</PopoverPanel>
</Popover>
);
};
export default PersonDropdown;
- we use the shorthand
[...Array(10).keys()]
to get an array containing all numbers from 0 to 9, so we can display the dropdown options - we style the numbers inside the popover using CSS Grid (https://tailwindcss.com/docs/grid-template-columns)
Adding the date picker
For the date picker, we can use the React DatePicker library, which seems to be the go to for showing date pickers in React.
We want the calendar to appear when the user clicks a button (instead of an input, as is the default). Looking over the docs, it seems like this is supported using the customInput
prop:
This is how we will use the calendar in the main component:
const [startDate, setStartDate] = useState(new Date());
...
<DateDropdown date={startDate} setDate={setStartDate} />
And here is the component code:
import { useState, forwardRef } from "react";
import DatePicker from "react-datepicker";
import "react-datepicker/dist/react-datepicker.css";
const DateDropdown = ({ date, setDate }) => {
const CustomDatePickerButton = forwardRef(
function renderCustomDatePickerButton({ value, onClick }, ref) {
return (
<button
className="bg-sky-100 py-3 px-5 rounded w-full text-left"
onClick={onClick}
ref={ref}
>
<span className="italic text-xs mr-7 min-w-10 inline-block">
Date
</span>
<span>{value}</span>
</button>
);
}
);
return (
<DatePicker
selected={date}
onChange={(date) => setDate(date)}
customInput={<CustomDatePickerButton />}
wrapperClassName="w-full"
/>
);
};
export default DateDropdown;
React DatePicker wraps our original container with its own wrappers, so we had to pass wrapperClassName="w-full"
to keep the form field 100% width.
! Note: There is currently a bug in this solution that the calendar "jumps" a bit when the picker is opened. I'm still investigating a fix for this.
Adding the time picker
The time picker is pretty much a select where each option is a time slot.
The closest component from Headless UI to what we need is the ListBox, so let's use that.
To keep things simple, let's create a custom component for this as well, that we can use as follows:
const [time, setTime] = useState(new Date());
...
<TimeDropdown time={time} setTime={setTime} />
To format the date as time, we can use Intl.DateTimeFormat
.
Let's add a helper function:
const formatTime = (date) => {
return Intl.DateTimeFormat("en-US", {
hour: "numeric",
hour12: false, // show as 24 hours
minute: "numeric",
}).format(date);
};
We want the default time to be 13:00.
Users can book a table between 12:00 and 21:00.
Time slots are open every 30 minutes.
To generate the timeslots for the dropdown, we can create a helper function:
const getTimeSlots = () => {
// Time slots start at 12:00
const timeSlotStart = new Date(0, 0, 0, 12, 0, 0);
// Time slots end at 21:00
const endTimeSlot = new Date(0, 0, 0, 21, 0, 0);
let timeSlots = [];
let timeSlot = new Date(timeSlotStart.getTime());
while (timeSlot.getTime() <= endTimeSlot.getTime()) {
timeSlots.push(timeSlot);
timeSlot = new Date(timeSlot.getTime() + 30 * 60 * 1000); // plus 30 minutes
}
return timeSlots;
};
The completed component is below:
import {
Listbox,
ListboxButton,
ListboxOption,
ListboxOptions,
} from "@headlessui/react";
const formatTime = (date) => {
return Intl.DateTimeFormat("en-US", {
hour: "numeric",
hour12: false, // show as 24 hours
minute: "numeric",
}).format(date);
};
const getTimeSlots = () => {
// Time slots start at 12:00
const timeSlotStart = new Date(0, 0, 0, 12, 0, 0);
// Time slots end at 21:00
const endTimeSlot = new Date(0, 0, 0, 21, 0, 0);
let timeSlots = [];
let timeSlot = new Date(timeSlotStart.getTime());
while (timeSlot.getTime() <= endTimeSlot.getTime()) {
timeSlots.push(timeSlot);
timeSlot = new Date(timeSlot.getTime() + 30 * 60 * 1000); // plus 30 minutes
}
return timeSlots;
};
const TimeDropdown = ({ time, setTime }) => {
const timeSlots = getTimeSlots();
return (
<Listbox value={time.getTime()} onChange={(t) => setTime(new Date(t))}>
<ListboxButton className="bg-sky-100 py-3 px-5 rounded w-full text-left">
<span className="italic text-xs mr-7 min-w-10 inline-block">Time</span>
<span>{formatTime(time)}</span>
</ListboxButton>
<ListboxOptions
anchor="bottom"
className="shadow-xl rounded box-content p-2 bg-slate-50 mt-2 w-80 h-60 "
>
{timeSlots.map((t) => (
<ListboxOption
key={t.getTime()}
value={t.getTime()}
className="rounded bg-slate-50 border-slate-300 border-2 py-2 m-2 px-4 text-sm data-[focus]:border-sky-500 data-[focus]:text-sky-500 data-[active]:border-sky-700 shadow-sm"
>
{formatTime(t)}
</ListboxOption>
))}
</ListboxOptions>
</Listbox>
);
};
export default TimeDropdown;
Going to the next step
Once the user has entered the details of the booking, he needs to fill in his name and phone number and then he can confirm the reservation.
For this, we'll need to switch to a different "screen" inside the dialog.
The simplest way to achieve this is define a "step" state variable, where we track at step the user is in our workflow.
For our use case, we have just two steps - let's call them reservation-details
and contact-details
.
In the main component, we now have the form elements grouped based on the current step:
import Button from "../ui/Button";
import {
Dialog,
DialogPanel,
DialogTitle,
DialogBackdrop,
} from "@headlessui/react";
import { useState } from "react";
import PersonDropdown from "./PersonDropdown";
import DateDropdown from "./DateDropdown";
import TimeDropdown from "./TimeDropdown";
const ReservationWidget = () => {
const [isOpen, setIsOpen] = useState(false);
const [personCount, setPersonCount] = useState(2);
const [startDate, setStartDate] = useState(new Date());
const [time, setTime] = useState(new Date(0, 0, 0, 12, 0, 0));
const [step, setStep] = useState("reservation-details");
return (
<>
<Button onClick={() => setIsOpen(true)}>Book a table</Button>
<Dialog
open={isOpen}
onClose={() => setIsOpen(false)}
className="relative z-50"
>
{/* The backdrop, rendered as a fixed sibling to the panel container */}
<DialogBackdrop className="fixed inset-0 bg-black/30" />
{/* Full-screen container to center the panel */}
<div className="fixed inset-0 flex w-screen items-center justify-center p-4">
<DialogPanel className="max-w-lg space-y-4 border bg-white p-12">
<DialogTitle className="font-bold">Book a table</DialogTitle>
{step === "reservation-details" && (
<>
<PersonDropdown count={personCount} setCount={setPersonCount} />
<DateDropdown date={startDate} setDate={setStartDate} />
<TimeDropdown time={time} setTime={setTime} />
<div className="flex gap-4">
<Button onClick={() => setStep("contact-details")}>
Book now
</Button>
</div>
</>
)}
{step === "contact-details" && (
<>
<p>Person details coming soon ...</p>
<div className="flex gap-4">
<Button onClick={() => setIsOpen(false)}>
Confirm reservation
</Button>
</div>
</>
)}
</DialogPanel>
</div>
</Dialog>
</>
);
};
export default ReservationWidget;
Entering the name and phone number
Last but not least, let's add two inputs for the name and phone:
We can use Headless UI Fieldset elements.
Here is the final ReservationWidget component, with everything in place:
import Button from "../ui/Button";
import { Field, Fieldset, Input, Label, Legend } from "@headlessui/react";
import {
Dialog,
DialogPanel,
DialogTitle,
DialogBackdrop,
} from "@headlessui/react";
import { useState } from "react";
import PersonDropdown from "./PersonDropdown";
import DateDropdown from "./DateDropdown";
import TimeDropdown from "./TimeDropdown";
import { formatTime, formatDate } from "../../utils/date-helpers";
const ReservationWidget = () => {
const [isOpen, setIsOpen] = useState(false);
const [personCount, setPersonCount] = useState(2);
const [startDate, setStartDate] = useState(new Date());
const [time, setTime] = useState(new Date(0, 0, 0, 12, 0, 0));
const [step, setStep] = useState("reservation-details");
const [name, setName] = useState(2);
const [phone, setPhone] = useState(2);
const logReservationDetails = () => {
console.log("Reservation successful!", {
personCount,
time,
startDate,
name,
phone,
});
};
return (
<>
<Button onClick={() => setIsOpen(true)}>Book a table</Button>
<Dialog
open={isOpen}
onClose={() => setIsOpen(false)}
className="relative z-50"
>
{/* The backdrop, rendered as a fixed sibling to the panel container */}
<DialogBackdrop className="fixed inset-0 bg-black/30" />
{/* Full-screen container to center the panel */}
<div className="fixed inset-0 flex w-screen items-center justify-center p-4">
<DialogPanel className="max-w-lg border bg-white p-12">
{step === "reservation-details" && (
<div className="space-y-3">
<DialogTitle className="font-bold">Book a table</DialogTitle>
<PersonDropdown count={personCount} setCount={setPersonCount} />
<DateDropdown date={startDate} setDate={setStartDate} />
<TimeDropdown time={time} setTime={setTime} />
<div className="flex gap-4">
<Button onClick={() => setStep("contact-details")}>
Book now
</Button>
</div>
</div>
)}
{step === "contact-details" && (
<>
<Fieldset className="space-y-3">
<Legend className="text-lg font-bold">Contact details</Legend>
<div className="bg-sky-100 p-3 my-5">
You are making a reservation for{" "}
<span className="font-bold">{personCount} persons</span>, on{" "}
<span className="font-bold">{formatDate(startDate)}</span>{" "}
at <span className="font-bold">{formatTime(time)}</span>
</div>
<Field>
<Label className="block">Name</Label>
<Input
className="border data-[hover]:shadow data-[focus]:bg-sky-100 w-full p-1"
name="name"
value={name}
onChange={(e) => setName(e.target.value)}
/>
</Field>
<Field>
<Label className="block">Phone number</Label>
<Input
className="border data-[hover]:shadow data-[focus]:bg-sky-100 w-full p-1"
name="phone"
value={phone}
onChange={(e) => setPhone(e.target.value)}
/>
</Field>
<Button
onClick={() => {
setStep("reservation-details");
logReservationDetails();
setIsOpen(false);
}}
>
Confirm reservation
</Button>
</Fieldset>
</>
)}
</DialogPanel>
</div>
</Dialog>
</>
);
};
export default ReservationWidget;
How did you solve this challenge? Share your thoughts in the comments below!
Member discussion