@balby/booking-search 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +254 -0
- package/dist/index.css +1668 -0
- package/dist/index.js +12681 -0
- package/package.json +84 -0
- package/src/components/booking-search/date-range-picker.tsx +240 -0
- package/src/components/booking-search/guest-selector.tsx +181 -0
- package/src/components/booking-search/index.tsx +199 -0
- package/src/components/booking-search/location-combobox.tsx +93 -0
- package/src/components/booking-search/tests/date-range-picker.test.tsx +308 -0
- package/src/components/booking-search/tests/guest-selector.test.tsx +358 -0
- package/src/components/booking-search/tests/index.test.tsx +424 -0
- package/src/components/booking-search/tests/location-combobox.test.tsx +263 -0
- package/src/components/booking-search/ui/command.tsx +100 -0
- package/src/components/booking-search/ui/dialog.tsx +88 -0
- package/src/components/booking-search/ui/popover.tsx +28 -0
- package/src/demo.tsx +198 -0
- package/src/index.ts +27 -0
- package/src/lib/utils.ts +6 -0
- package/src/styles/globals.css +158 -0
- package/src/styles/output.css +4 -0
- package/src/styles/output.css.map +1 -0
- package/src/types/booking.ts +155 -0
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
import * as React from "react"
|
|
2
|
+
import { Search } from "lucide-react"
|
|
3
|
+
import { cn } from "../../lib/utils"
|
|
4
|
+
import type { BookingSearchProps, SearchLocation, GuestData } from "../../types/booking"
|
|
5
|
+
import { LocationCombobox } from "./location-combobox"
|
|
6
|
+
import { DateRangePicker } from "./date-range-picker"
|
|
7
|
+
import { GuestSelector } from "./guest-selector"
|
|
8
|
+
import {
|
|
9
|
+
Dialog,
|
|
10
|
+
DialogContent,
|
|
11
|
+
DialogHeader,
|
|
12
|
+
DialogTitle,
|
|
13
|
+
DialogTrigger,
|
|
14
|
+
} from "./ui/dialog"
|
|
15
|
+
import type {JSX} from "react"
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* A component that provides a booking search interface. Users can select a destination, date range, and number of guests.
|
|
19
|
+
* It supports both desktop and mobile layouts.
|
|
20
|
+
*
|
|
21
|
+
* @param {Object} props - The properties object.
|
|
22
|
+
* @param {Array} props.availability - The data specifying available dates for booking.
|
|
23
|
+
* @param {Array} props.locations - The list of available locations for selection.
|
|
24
|
+
* @param {Function} props.onSearch - Callback function triggered when the search is submitted. Receives selected search criteria as an argument.
|
|
25
|
+
* @param {Object} [props.defaultValues] - Default values for location, check-in and check-out dates, and guest count.
|
|
26
|
+
* @param {string} [props.searchButtonText='Cerca'] - Text for the search button.
|
|
27
|
+
* @param {string} [props.locationPlaceholder='Dove vuoi andare?'] - Placeholder text for the location input field.
|
|
28
|
+
* @param {number} [props.minNights=1] - Minimum number of nights for the date range picker.
|
|
29
|
+
* @param {number} [props.maxAdults=30] - Maximum number of adult guests allowed.
|
|
30
|
+
* @param {number} [props.maxChildren=10] - Maximum number of child guests allowed.
|
|
31
|
+
* @param {string} [props.className] - Additional CSS class for customizing the component style.
|
|
32
|
+
*
|
|
33
|
+
* @return {JSX.Element} A booking search component containing inputs for location, date range, and guest selection, along with a search button.
|
|
34
|
+
*/
|
|
35
|
+
export function BookingSearch({
|
|
36
|
+
availability,
|
|
37
|
+
locations,
|
|
38
|
+
onSearch,
|
|
39
|
+
defaultValues,
|
|
40
|
+
searchButtonText = "Search",
|
|
41
|
+
locationPlaceholder = "Where do you want to go?",
|
|
42
|
+
minNights = 1,
|
|
43
|
+
maxAdults = 30,
|
|
44
|
+
maxChildren = 10,
|
|
45
|
+
className,
|
|
46
|
+
}: BookingSearchProps): JSX.Element {
|
|
47
|
+
const [location, setLocation] = React.useState<SearchLocation | null>(
|
|
48
|
+
defaultValues?.location ?? null
|
|
49
|
+
)
|
|
50
|
+
const [dateRange, setDateRange] = React.useState<{
|
|
51
|
+
from: Date | null
|
|
52
|
+
to: Date | null
|
|
53
|
+
}>({
|
|
54
|
+
from: defaultValues?.checkIn ?? null,
|
|
55
|
+
to: defaultValues?.checkOut ?? null,
|
|
56
|
+
})
|
|
57
|
+
const [guests, setGuests] = React.useState<GuestData>({
|
|
58
|
+
adults: defaultValues?.adults ?? 2,
|
|
59
|
+
children: defaultValues?.children ?? 0,
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
const [mobileOpen, setMobileOpen] = React.useState(false)
|
|
63
|
+
|
|
64
|
+
const [isMobile, setIsMobile] = React.useState(false)
|
|
65
|
+
|
|
66
|
+
React.useEffect(() => {
|
|
67
|
+
const checkMobile = () => {
|
|
68
|
+
setIsMobile(window.innerWidth < 768)
|
|
69
|
+
}
|
|
70
|
+
checkMobile()
|
|
71
|
+
window.addEventListener("resize", checkMobile)
|
|
72
|
+
return () => window.removeEventListener("resize", checkMobile)
|
|
73
|
+
}, [])
|
|
74
|
+
|
|
75
|
+
const handleSearch = () => {
|
|
76
|
+
onSearch({
|
|
77
|
+
location,
|
|
78
|
+
checkIn: dateRange.from,
|
|
79
|
+
checkOut: dateRange.to,
|
|
80
|
+
adults: guests.adults,
|
|
81
|
+
children: guests.children,
|
|
82
|
+
})
|
|
83
|
+
if (isMobile) {
|
|
84
|
+
setMobileOpen(false)
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const isSearchEnabled = location && dateRange.from && dateRange.to
|
|
89
|
+
|
|
90
|
+
const SearchContent = () => (
|
|
91
|
+
<div className="flex flex-col gap-3 md:flex-row md:gap-2 md:items-center">
|
|
92
|
+
<div className="flex-1 relative">
|
|
93
|
+
<LocationCombobox
|
|
94
|
+
title="Destination"
|
|
95
|
+
locations={locations}
|
|
96
|
+
value={location}
|
|
97
|
+
onChange={setLocation}
|
|
98
|
+
placeholder={locationPlaceholder}
|
|
99
|
+
className="w-full border-0 md:rounded-l-lg md:rounded-r-none focus-visible:ring-0"
|
|
100
|
+
tabIndex={1}
|
|
101
|
+
/>
|
|
102
|
+
</div>
|
|
103
|
+
|
|
104
|
+
<div className="flex-1 relative">
|
|
105
|
+
<DateRangePicker
|
|
106
|
+
availability={availability}
|
|
107
|
+
value={dateRange}
|
|
108
|
+
onChange={setDateRange}
|
|
109
|
+
minNights={minNights}
|
|
110
|
+
className="w-full border-0 md:rounded-none focus-visible:ring-0"
|
|
111
|
+
tabIndex={2}
|
|
112
|
+
/>
|
|
113
|
+
</div>
|
|
114
|
+
|
|
115
|
+
<div className="flex-1 relative">
|
|
116
|
+
<GuestSelector
|
|
117
|
+
value={guests}
|
|
118
|
+
onChange={setGuests}
|
|
119
|
+
maxAdults={maxAdults}
|
|
120
|
+
maxChildren={maxChildren}
|
|
121
|
+
className="w-full border-0 md:rounded-none focus-visible:ring-0"
|
|
122
|
+
tabIndex={3}
|
|
123
|
+
/>
|
|
124
|
+
</div>
|
|
125
|
+
|
|
126
|
+
<div className="md:pl-2">
|
|
127
|
+
<button
|
|
128
|
+
type="button"
|
|
129
|
+
onClick={handleSearch}
|
|
130
|
+
disabled={!isSearchEnabled}
|
|
131
|
+
aria-label={searchButtonText}
|
|
132
|
+
tabIndex={4}
|
|
133
|
+
className={cn(
|
|
134
|
+
"flex h-12 w-full items-center justify-center gap-2 rounded-lg bg-blue-600 px-6 text-sm font-bold text-white transition-all hover:bg-blue-700 disabled:bg-slate-300 md:w-auto",
|
|
135
|
+
)}
|
|
136
|
+
>
|
|
137
|
+
<Search className="h-4 w-4" />
|
|
138
|
+
<span className="whitespace-nowrap">{searchButtonText}</span>
|
|
139
|
+
</button>
|
|
140
|
+
</div>
|
|
141
|
+
</div>
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
if (!isMobile) {
|
|
145
|
+
return (
|
|
146
|
+
<div
|
|
147
|
+
className={cn(
|
|
148
|
+
"w-full rounded-2xl border border-slate-200 bg-white p-3 shadow-xl",
|
|
149
|
+
className
|
|
150
|
+
)}
|
|
151
|
+
role="search"
|
|
152
|
+
aria-label="Booking search"
|
|
153
|
+
>
|
|
154
|
+
<SearchContent />
|
|
155
|
+
</div>
|
|
156
|
+
)
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
return (
|
|
160
|
+
<>
|
|
161
|
+
<Dialog open={mobileOpen} onOpenChange={setMobileOpen}>
|
|
162
|
+
<DialogTrigger asChild>
|
|
163
|
+
<button
|
|
164
|
+
type="button"
|
|
165
|
+
tabIndex={0}
|
|
166
|
+
className={cn(
|
|
167
|
+
"flex h-14 w-full items-center gap-3 rounded-lg border border-slate-300 bg-white px-4 shadow-md",
|
|
168
|
+
className
|
|
169
|
+
)}
|
|
170
|
+
aria-label="Open booking search"
|
|
171
|
+
>
|
|
172
|
+
<Search className="h-5 w-5 text-slate-500" aria-hidden="true" />
|
|
173
|
+
<div className="flex flex-col items-start text-left">
|
|
174
|
+
<span className="text-sm font-semibold text-slate-900">
|
|
175
|
+
Dove vuoi andare?
|
|
176
|
+
</span>
|
|
177
|
+
<span className="text-xs text-slate-500">
|
|
178
|
+
{location?.name ?? "Destination"} • {guests.adults + guests.children}{" "}
|
|
179
|
+
{guests.adults + guests.children === 1 ? "ospite" : "ospiti"}
|
|
180
|
+
</span>
|
|
181
|
+
</div>
|
|
182
|
+
</button>
|
|
183
|
+
</DialogTrigger>
|
|
184
|
+
<DialogContent className="max-h-[90vh] overflow-y-auto sm:max-w-[600px]">
|
|
185
|
+
<DialogHeader>
|
|
186
|
+
<DialogTitle>Search your accommodation</DialogTitle>
|
|
187
|
+
</DialogHeader>
|
|
188
|
+
<div className="mt-4">
|
|
189
|
+
<SearchContent />
|
|
190
|
+
</div>
|
|
191
|
+
</DialogContent>
|
|
192
|
+
</Dialog>
|
|
193
|
+
</>
|
|
194
|
+
)
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
export { LocationCombobox } from "./location-combobox"
|
|
198
|
+
export { DateRangePicker } from "./date-range-picker"
|
|
199
|
+
export { GuestSelector } from "./guest-selector"
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import * as React from "react"
|
|
2
|
+
import {Check, MapPin} from "lucide-react"
|
|
3
|
+
import {cn} from "../../lib/utils"
|
|
4
|
+
import type {LocationComboboxProps} from "../../types/booking"
|
|
5
|
+
import {
|
|
6
|
+
Command,
|
|
7
|
+
CommandEmpty,
|
|
8
|
+
CommandGroup,
|
|
9
|
+
CommandInput,
|
|
10
|
+
CommandItem,
|
|
11
|
+
CommandList,
|
|
12
|
+
} from "./ui/command"
|
|
13
|
+
import {Popover, PopoverContent, PopoverTrigger} from "./ui/popover"
|
|
14
|
+
|
|
15
|
+
export function LocationCombobox({
|
|
16
|
+
locations,
|
|
17
|
+
value,
|
|
18
|
+
onChange,
|
|
19
|
+
placeholder = "Where do you want to stay?",
|
|
20
|
+
disabled = false,
|
|
21
|
+
className,
|
|
22
|
+
tabIndex,
|
|
23
|
+
title = "Destination"
|
|
24
|
+
}: LocationComboboxProps) {
|
|
25
|
+
const [open, setOpen] = React.useState(false)
|
|
26
|
+
|
|
27
|
+
return (
|
|
28
|
+
<Popover open={open} onOpenChange={setOpen}>
|
|
29
|
+
<PopoverTrigger asChild>
|
|
30
|
+
<button
|
|
31
|
+
type="button"
|
|
32
|
+
role="combobox"
|
|
33
|
+
aria-expanded={open}
|
|
34
|
+
aria-label="Select location"
|
|
35
|
+
disabled={disabled}
|
|
36
|
+
tabIndex={tabIndex}
|
|
37
|
+
className={cn(
|
|
38
|
+
"flex h-14 w-full items-center gap-3 rounded-lg border border-slate-300 bg-white px-4 py-3 text-left text-sm transition-colors hover:bg-slate-50 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
|
|
39
|
+
className
|
|
40
|
+
)}
|
|
41
|
+
>
|
|
42
|
+
<MapPin className="h-5 w-5 text-slate-500 flex-shrink-0" aria-hidden="true"/>
|
|
43
|
+
<div className="flex flex-col min-w-0 flex-1">
|
|
44
|
+
<span className="text-xs font-medium text-slate-500">{title}</span>
|
|
45
|
+
{value ? (
|
|
46
|
+
<span className="font-medium text-slate-900 truncate">{value.name}</span>
|
|
47
|
+
) : (
|
|
48
|
+
<span className="text-slate-400 truncate">{placeholder}</span>
|
|
49
|
+
)}
|
|
50
|
+
</div>
|
|
51
|
+
</button>
|
|
52
|
+
</PopoverTrigger>
|
|
53
|
+
<PopoverContent className="w-[var(--radix-popover-trigger-width)] p-0" align="start">
|
|
54
|
+
<Command>
|
|
55
|
+
<CommandInput placeholder="Search a destination..."/>
|
|
56
|
+
<CommandList>
|
|
57
|
+
<CommandEmpty>No location found.</CommandEmpty>
|
|
58
|
+
<CommandGroup>
|
|
59
|
+
{locations.map((location) => (
|
|
60
|
+
<CommandItem
|
|
61
|
+
key={location.id}
|
|
62
|
+
value={location.name}
|
|
63
|
+
onSelect={() => {
|
|
64
|
+
onChange(location.id === value?.id ? null : location)
|
|
65
|
+
setOpen(false)
|
|
66
|
+
}}
|
|
67
|
+
className="flex items-center justify-between"
|
|
68
|
+
>
|
|
69
|
+
<div className="flex items-center gap-2">
|
|
70
|
+
<MapPin className="h-4 w-4 text-slate-500" aria-hidden="true"/>
|
|
71
|
+
<div className="flex flex-col">
|
|
72
|
+
<span className="font-medium">{location.name}</span>
|
|
73
|
+
{location.type && (
|
|
74
|
+
<span className="text-xs text-slate-500">{location.type}</span>
|
|
75
|
+
)}
|
|
76
|
+
</div>
|
|
77
|
+
</div>
|
|
78
|
+
<Check
|
|
79
|
+
className={cn(
|
|
80
|
+
"h-4 w-4",
|
|
81
|
+
value?.id === location.id ? "opacity-100" : "opacity-0"
|
|
82
|
+
)}
|
|
83
|
+
aria-hidden="true"
|
|
84
|
+
/>
|
|
85
|
+
</CommandItem>
|
|
86
|
+
))}
|
|
87
|
+
</CommandGroup>
|
|
88
|
+
</CommandList>
|
|
89
|
+
</Command>
|
|
90
|
+
</PopoverContent>
|
|
91
|
+
</Popover>
|
|
92
|
+
)
|
|
93
|
+
}
|
|
@@ -0,0 +1,308 @@
|
|
|
1
|
+
import { test, expect, describe, beforeEach } from "bun:test"
|
|
2
|
+
import React from "react"
|
|
3
|
+
import { render, screen, fireEvent, waitFor } from "@testing-library/react"
|
|
4
|
+
import { DateRangePicker } from "../date-range-picker"
|
|
5
|
+
import type { AvailabilityDay } from "../../../types/booking"
|
|
6
|
+
import { format, addDays } from "date-fns"
|
|
7
|
+
|
|
8
|
+
describe("DateRangePicker", () => {
|
|
9
|
+
const today = new Date()
|
|
10
|
+
const tomorrow = addDays(today, 1)
|
|
11
|
+
const nextWeek = addDays(today, 7)
|
|
12
|
+
|
|
13
|
+
const mockAvailability: AvailabilityDay[] = Array.from({ length: 30 }, (_, i) => {
|
|
14
|
+
const date = addDays(today, i)
|
|
15
|
+
return {
|
|
16
|
+
date: format(date, "yyyy-MM-dd"),
|
|
17
|
+
isAvailable: true,
|
|
18
|
+
price: 100 + i * 5,
|
|
19
|
+
}
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
const mockValue = {
|
|
23
|
+
from: null as Date | null,
|
|
24
|
+
to: null as Date | null,
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const mockOnChange = (range: { from: Date | null; to: Date | null }) => {}
|
|
28
|
+
|
|
29
|
+
beforeEach(() => {
|
|
30
|
+
// Reset any state between tests if needed
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
test("renders with placeholder when no dates are selected", () => {
|
|
34
|
+
render(
|
|
35
|
+
<DateRangePicker
|
|
36
|
+
availability={mockAvailability}
|
|
37
|
+
value={mockValue}
|
|
38
|
+
onChange={mockOnChange}
|
|
39
|
+
/>
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
expect(screen.getByText("Select Date Range")).toBeDefined()
|
|
43
|
+
expect(screen.getByText("Check-in - Check-out")).toBeDefined()
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
test("displays selected date range", () => {
|
|
47
|
+
render(
|
|
48
|
+
<DateRangePicker
|
|
49
|
+
availability={mockAvailability}
|
|
50
|
+
value={{ from: today, to: nextWeek }}
|
|
51
|
+
onChange={mockOnChange}
|
|
52
|
+
/>
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
const dateText = screen.getByText(/\d{2} \w{3} - \d{2} \w{3}/)
|
|
56
|
+
expect(dateText).toBeDefined()
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
test("displays only check-in date when check-out is not selected", () => {
|
|
60
|
+
render(
|
|
61
|
+
<DateRangePicker
|
|
62
|
+
availability={mockAvailability}
|
|
63
|
+
value={{ from: today, to: null }}
|
|
64
|
+
onChange={mockOnChange}
|
|
65
|
+
/>
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
const dateText = screen.getByText(/\d{2} \w{3} \d{4}/)
|
|
69
|
+
expect(dateText).toBeDefined()
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
test("opens popover when button is clicked", async () => {
|
|
73
|
+
render(
|
|
74
|
+
<DateRangePicker
|
|
75
|
+
availability={mockAvailability}
|
|
76
|
+
value={mockValue}
|
|
77
|
+
onChange={mockOnChange}
|
|
78
|
+
/>
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
const button = screen.getByRole("button", { name: "Select Date Range" })
|
|
82
|
+
fireEvent.click(button)
|
|
83
|
+
|
|
84
|
+
await waitFor(() => {
|
|
85
|
+
expect(screen.getByText("Confirm")).toBeDefined()
|
|
86
|
+
expect(screen.getByText("Cancel")).toBeDefined()
|
|
87
|
+
})
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
test("selects check-in date on first click", async () => {
|
|
91
|
+
let currentValue = { from: null as Date | null, to: null as Date | null }
|
|
92
|
+
const handleChange = (range: { from: Date | null; to: Date | null }) => {
|
|
93
|
+
currentValue = range
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
render(
|
|
97
|
+
<DateRangePicker
|
|
98
|
+
availability={mockAvailability}
|
|
99
|
+
value={currentValue}
|
|
100
|
+
onChange={handleChange}
|
|
101
|
+
/>
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
const button = screen.getByRole("button", { name: "Select Date Range" })
|
|
105
|
+
fireEvent.click(button)
|
|
106
|
+
|
|
107
|
+
await waitFor(() => {
|
|
108
|
+
expect(screen.getByText("Confirm")).toBeDefined()
|
|
109
|
+
})
|
|
110
|
+
})
|
|
111
|
+
|
|
112
|
+
test("calls onChange with selected date range when confirm is clicked", async () => {
|
|
113
|
+
let currentValue = { from: null as Date | null, to: null as Date | null }
|
|
114
|
+
const handleChange = (range: { from: Date | null; to: Date | null }) => {
|
|
115
|
+
currentValue = range
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
render(
|
|
119
|
+
<DateRangePicker
|
|
120
|
+
availability={mockAvailability}
|
|
121
|
+
value={currentValue}
|
|
122
|
+
onChange={handleChange}
|
|
123
|
+
/>
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
const button = screen.getByRole("button", { name: "Select Date Range" })
|
|
127
|
+
fireEvent.click(button)
|
|
128
|
+
|
|
129
|
+
await waitFor(() => {
|
|
130
|
+
const confirmButton = screen.getByText("Confirm")
|
|
131
|
+
expect(confirmButton).toBeDefined()
|
|
132
|
+
// Confirm button should be disabled when no dates are selected
|
|
133
|
+
expect(confirmButton.hasAttribute("disabled")).toBe(true)
|
|
134
|
+
})
|
|
135
|
+
})
|
|
136
|
+
|
|
137
|
+
test("does not call onChange until confirm is clicked", async () => {
|
|
138
|
+
let changeCallCount = 0
|
|
139
|
+
const handleChange = (range: { from: Date | null; to: Date | null }) => {
|
|
140
|
+
changeCallCount++
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
render(
|
|
144
|
+
<DateRangePicker
|
|
145
|
+
availability={mockAvailability}
|
|
146
|
+
value={{ from: null, to: null }}
|
|
147
|
+
onChange={handleChange}
|
|
148
|
+
/>
|
|
149
|
+
)
|
|
150
|
+
|
|
151
|
+
const button = screen.getByRole("button", { name: "Select Date Range" })
|
|
152
|
+
fireEvent.click(button)
|
|
153
|
+
|
|
154
|
+
await waitFor(() => {
|
|
155
|
+
expect(screen.getByText("Confirm")).toBeDefined()
|
|
156
|
+
})
|
|
157
|
+
|
|
158
|
+
// Clicking on calendar dates would happen here, but that requires more complex interaction
|
|
159
|
+
// onChange should not have been called yet
|
|
160
|
+
expect(changeCallCount).toBe(0)
|
|
161
|
+
})
|
|
162
|
+
|
|
163
|
+
test("resets to original value when cancel is clicked", async () => {
|
|
164
|
+
const { rerender } = render(
|
|
165
|
+
<DateRangePicker
|
|
166
|
+
availability={mockAvailability}
|
|
167
|
+
value={{ from: today, to: nextWeek }}
|
|
168
|
+
onChange={mockOnChange}
|
|
169
|
+
/>
|
|
170
|
+
)
|
|
171
|
+
|
|
172
|
+
const button = screen.getByRole("button", { name: "Select Date Range" })
|
|
173
|
+
fireEvent.click(button)
|
|
174
|
+
|
|
175
|
+
await waitFor(() => {
|
|
176
|
+
const cancelButton = screen.getByText("Cancel")
|
|
177
|
+
fireEvent.click(cancelButton)
|
|
178
|
+
})
|
|
179
|
+
|
|
180
|
+
// Popover should close
|
|
181
|
+
await waitFor(() => {
|
|
182
|
+
expect(screen.queryByText("Cancel")).toBeNull()
|
|
183
|
+
})
|
|
184
|
+
})
|
|
185
|
+
|
|
186
|
+
test("displays night count when both dates are selected", async () => {
|
|
187
|
+
render(
|
|
188
|
+
<DateRangePicker
|
|
189
|
+
availability={mockAvailability}
|
|
190
|
+
value={{ from: today, to: nextWeek }}
|
|
191
|
+
onChange={mockOnChange}
|
|
192
|
+
/>
|
|
193
|
+
)
|
|
194
|
+
|
|
195
|
+
const button = screen.getByRole("button", { name: "Select Date Range" })
|
|
196
|
+
fireEvent.click(button)
|
|
197
|
+
|
|
198
|
+
await waitFor(() => {
|
|
199
|
+
expect(screen.getByText("7 nights")).toBeDefined()
|
|
200
|
+
})
|
|
201
|
+
})
|
|
202
|
+
|
|
203
|
+
test("displays singular form for one night", async () => {
|
|
204
|
+
render(
|
|
205
|
+
<DateRangePicker
|
|
206
|
+
availability={mockAvailability}
|
|
207
|
+
value={{ from: today, to: tomorrow }}
|
|
208
|
+
onChange={mockOnChange}
|
|
209
|
+
/>
|
|
210
|
+
)
|
|
211
|
+
|
|
212
|
+
const button = screen.getByRole("button", { name: "Select Date Range" })
|
|
213
|
+
fireEvent.click(button)
|
|
214
|
+
|
|
215
|
+
await waitFor(() => {
|
|
216
|
+
expect(screen.getByText("1 night")).toBeDefined()
|
|
217
|
+
})
|
|
218
|
+
})
|
|
219
|
+
|
|
220
|
+
test("disables the button when disabled prop is true", () => {
|
|
221
|
+
render(
|
|
222
|
+
<DateRangePicker
|
|
223
|
+
availability={mockAvailability}
|
|
224
|
+
value={mockValue}
|
|
225
|
+
onChange={mockOnChange}
|
|
226
|
+
disabled={true}
|
|
227
|
+
/>
|
|
228
|
+
)
|
|
229
|
+
|
|
230
|
+
const button = screen.getByRole("button", { name: "Select Date Range" })
|
|
231
|
+
expect(button.hasAttribute("disabled")).toBe(true)
|
|
232
|
+
})
|
|
233
|
+
|
|
234
|
+
test("applies custom className", () => {
|
|
235
|
+
const { container } = render(
|
|
236
|
+
<DateRangePicker
|
|
237
|
+
availability={mockAvailability}
|
|
238
|
+
value={mockValue}
|
|
239
|
+
onChange={mockOnChange}
|
|
240
|
+
className="custom-class"
|
|
241
|
+
/>
|
|
242
|
+
)
|
|
243
|
+
|
|
244
|
+
const button = container.querySelector("button")
|
|
245
|
+
expect(button?.classList.contains("custom-class")).toBe(true)
|
|
246
|
+
})
|
|
247
|
+
|
|
248
|
+
test("closes popover after confirming selection", async () => {
|
|
249
|
+
render(
|
|
250
|
+
<DateRangePicker
|
|
251
|
+
availability={mockAvailability}
|
|
252
|
+
value={{ from: today, to: tomorrow }}
|
|
253
|
+
onChange={mockOnChange}
|
|
254
|
+
/>
|
|
255
|
+
)
|
|
256
|
+
|
|
257
|
+
const button = screen.getByRole("button", { name: "Select Date Range" })
|
|
258
|
+
fireEvent.click(button)
|
|
259
|
+
|
|
260
|
+
await waitFor(() => {
|
|
261
|
+
expect(screen.getByText("Confirm")).toBeDefined()
|
|
262
|
+
})
|
|
263
|
+
|
|
264
|
+
const confirmButton = screen.getByText("Confirm")
|
|
265
|
+
fireEvent.click(confirmButton)
|
|
266
|
+
|
|
267
|
+
await waitFor(() => {
|
|
268
|
+
expect(screen.queryByText("Confirm")).toBeNull()
|
|
269
|
+
})
|
|
270
|
+
})
|
|
271
|
+
|
|
272
|
+
test("shows correct message when only check-in is selected", async () => {
|
|
273
|
+
render(
|
|
274
|
+
<DateRangePicker
|
|
275
|
+
availability={mockAvailability}
|
|
276
|
+
value={{ from: today, to: null }}
|
|
277
|
+
onChange={mockOnChange}
|
|
278
|
+
/>
|
|
279
|
+
)
|
|
280
|
+
|
|
281
|
+
const button = screen.getByRole("button", { name: "Select Date Range" })
|
|
282
|
+
fireEvent.click(button)
|
|
283
|
+
|
|
284
|
+
await waitFor(() => {
|
|
285
|
+
expect(screen.getByText("Select check-out")).toBeDefined()
|
|
286
|
+
})
|
|
287
|
+
})
|
|
288
|
+
|
|
289
|
+
test("prevents interaction outside from closing popover", async () => {
|
|
290
|
+
render(
|
|
291
|
+
<DateRangePicker
|
|
292
|
+
availability={mockAvailability}
|
|
293
|
+
value={mockValue}
|
|
294
|
+
onChange={mockOnChange}
|
|
295
|
+
/>
|
|
296
|
+
)
|
|
297
|
+
|
|
298
|
+
const button = screen.getByRole("button", { name: "Select Date Range" })
|
|
299
|
+
fireEvent.click(button)
|
|
300
|
+
|
|
301
|
+
await waitFor(() => {
|
|
302
|
+
expect(screen.getByText("Confirm")).toBeDefined()
|
|
303
|
+
})
|
|
304
|
+
|
|
305
|
+
// Try to close by clicking outside - this is prevented by onInteractOutside
|
|
306
|
+
// The popover should remain open
|
|
307
|
+
})
|
|
308
|
+
})
|