Files
web/packages/booking-flow/lib/components/BookingWidget/GuestsRoomsPicker/index.tsx
Matilda Haneling 4b67ffa7fd Merged in fix/book-149-datepicker-overflowing (pull request #3433)
Fix/optimizations and cleanup of booking widget

* fix(BOOK-149):fixed issue with datepicker overflowing on months spanning more weeks

* fix(BOOK-149): added smooth scroll to age selector to avoid clipping the selector

* cleanup in trigger and css

* update to new Button componenet to fix missing focus indicator

* included color token in triggerbutton class instead


Approved-by: Bianca Widstam
Approved-by: Erik Tiekstra
2026-01-15 11:00:31 +00:00

256 lines
6.7 KiB
TypeScript

"use client"
import { useCallback, useEffect, useRef, useState } from "react"
import { FocusScope, useOverlay } from "react-aria"
import { Button as ButtonRAC } from "react-aria-components"
import { useFormContext, useWatch } from "react-hook-form"
import { useIntl } from "react-intl"
import { useMediaQuery } from "usehooks-ts"
import { useScrollLock } from "@scandic-hotels/common/hooks/useScrollLock"
import { Typography } from "@scandic-hotels/design-system/Typography"
import ValidationError from "./ValidationError/index"
import PickerForm from "./Form"
import styles from "./guests-rooms-picker.module.css"
import type { GuestsRoom } from ".."
import type { BookingWidgetSchema } from "../Client"
export default function GuestsRoomsPickerForm({
ariaLabelledBy,
}: {
ariaLabelledBy?: string
}) {
const rooms = useWatch<BookingWidgetSchema, "rooms">({ name: "rooms" })
const checkIsDesktop = useMediaQuery("(min-width: 1367px)")
const [isDesktop, setIsDesktop] = useState(true)
const [isOpen, setIsOpen] = useState(false)
const [containerConstraint, setContainerConstraint] = useState(0)
const [showErrorModal, setShowErrorModal] = useState(false)
const ref = useRef<HTMLDivElement | null>(null)
// const childCount = rooms[0]?.childrenInRoom.length ?? 0 // ToDo Update for multiroom later
const {
clearErrors,
formState: { errors },
} = useFormContext()
const [scrollPosition, setScrollPosition] = useState(0)
const roomError = errors["rooms"]
const { lockScroll, unlockScroll } = useScrollLock({
autoLock: false,
})
useEffect(() => {
if (roomError) {
setShowErrorModal(true)
}
}, [roomError])
useEffect(() => {
clearErrors("rooms")
}, [clearErrors])
const timeoutRef = useRef<NodeJS.Timeout | null>(null)
useEffect(() => {
if (!roomError) return
if (timeoutRef.current) return
if (roomError) {
timeoutRef.current = setTimeout(() => {
setShowErrorModal(false)
// magic number originates from animation
// 5000ms delay + 120ms exectuion
timeoutRef.current = null
}, 5120)
}
return () => {}
}, [clearErrors, roomError])
useEffect(() => {
setIsDesktop(checkIsDesktop)
}, [checkIsDesktop])
const updateHeight = useCallback((containerConstraint: number) => {
// Get available space for picker to show without going beyond screen
const bookingWidget = document.getElementById("booking-widget")
const popoverElement = document.getElementById("guestsPopover")
const maxHeight =
window.innerHeight -
(bookingWidget?.getBoundingClientRect().bottom ?? 0) -
50
const innerContainerHeight = popoverElement?.getBoundingClientRect().height
const shouldAdjustHeight = Boolean(
// height should be constrained
maxHeight != containerConstraint &&
innerContainerHeight &&
maxHeight <= innerContainerHeight
)
const hasExcessVerticalSpace = Boolean(
// no need to constrain height
containerConstraint &&
innerContainerHeight &&
Math.floor(maxHeight) > Math.floor(innerContainerHeight)
)
if (shouldAdjustHeight) {
// avoid clipping if there's only one room
setContainerConstraint(Math.max(200, maxHeight))
} else if (hasExcessVerticalSpace) {
setContainerConstraint(0)
}
}, [])
useEffect(() => {
if (isDesktop && rooms.length > 1) {
updateHeight(containerConstraint)
}
}, [
isOpen,
scrollPosition,
isDesktop,
updateHeight,
containerConstraint,
rooms,
])
useEffect(() => {
if (isOpen && isDesktop) {
const handleScroll = () => {
setScrollPosition(window.scrollY)
}
window.addEventListener("scroll", handleScroll)
return () => {
window.removeEventListener("scroll", handleScroll)
}
}
}, [isOpen, isDesktop, rooms])
const { overlayProps, underlayProps } = useOverlay(
{
isOpen,
onClose: () => {
setIsOpen(false)
unlockScroll()
},
isDismissable: !errors.rooms,
},
ref
)
return (
<>
<div className={styles.container}>
<Trigger
rooms={rooms}
onPress={() => {
setIsOpen((prev) => !prev)
if (!isDesktop && !isOpen) {
lockScroll()
} else {
unlockScroll()
}
}}
ariaLabelledBy={ariaLabelledBy}
/>
{isOpen && (
<div {...underlayProps}>
<FocusScope contain restoreFocus autoFocus>
<div
id="guestsPopover"
{...overlayProps}
ref={ref}
className={styles.pickerContainer}
data-pressed={isOpen}
data-rooms-open={isOpen}
style={
isDesktop
? containerConstraint > 0
? { maxHeight: containerConstraint }
: { maxHeight: "none" }
: undefined
}
>
<PickerForm
rooms={rooms}
onClose={() => {
setIsOpen((prev) => !prev)
unlockScroll()
}}
/>
</div>
</FocusScope>
</div>
)}
</div>
{showErrorModal && !isOpen && errors.rooms && <ValidationError />}
</>
)
}
function Trigger({
rooms,
onPress,
ariaLabelledBy,
}: {
rooms: GuestsRoom[]
onPress?: () => void
ariaLabelledBy?: string
}) {
const intl = useIntl()
const parts = [
intl.formatMessage(
{
id: "booking.numberOfRooms",
defaultMessage: "{totalRooms, plural, one {# room} other {# rooms}}",
},
{ totalRooms: rooms.length }
),
intl.formatMessage(
{
id: "booking.numberOfAdults",
defaultMessage: "{adults, plural, one {# adult} other {# adults}}",
},
{ adults: rooms.reduce((acc, room) => acc + room.adults, 0) }
),
]
if (rooms.some((room) => room.childrenInRoom.length > 0)) {
parts.push(
intl.formatMessage(
{
id: "booking.numberOfChildren",
defaultMessage:
"{children, plural, one {# child} other {# children}}",
},
{
children: rooms.reduce(
(acc, room) => acc + room.childrenInRoom.length,
0
),
}
)
)
}
return (
<Typography variant="Body/Paragraph/mdRegular">
<ButtonRAC
className={styles.triggerButton}
type="button"
onPress={onPress}
aria-labelledby={ariaLabelledBy}
>
{parts.join(", ")}
</ButtonRAC>
</Typography>
)
}