Merged in fix/book-149-incorrect-onfocus-behaviour-booking-widget (pull request #3320)

Fix/book 149 incorrect onfocus behaviour booking widget

* fix(BOOK-149): fixed labels shifting

* fix(BOOK-149): reintroduced sticky position

* fix(BOOK-149): added missing border to "where" text field

* added overflow to datepicker

* comment fixes

* removed separate typography declaration

* changed to onPress

* fix(BOOK-149): moved components to separate files

* fix(BOOK-149): removed desktop & mobile specific css classes

* fix(BOOK-149): new implementation of date and room modals

* dependencies update

* fix(BOOK-149): fixed child age dropdown issue, related error message, and Rooms & Guests container height

* updated info button to new variant

* fix(BOOK-149): prevent scrolling of background when modals are open in Tablet mode

* fixed overlay issue and added focus indicator on mobile

* fixed missing space in css

* rebase and fixed icon buttons after update

* simplified to use explicit boolean

* PR comments fixes

* more PR comment fixes

* PR comment fixes

* fixed setIsOpen((prev) => !prev)

* fixed issues with room error not showing properly on mobile

* fixing pr comments

* fixed flickering on GuestRoomModal


Approved-by: Erik Tiekstra
This commit is contained in:
Matilda Haneling
2026-01-12 14:18:51 +00:00
parent 0c6a4cf186
commit 6a008ba342
38 changed files with 1117 additions and 743 deletions

View File

@@ -1,19 +1,16 @@
"use client"
import { useCallback, useEffect, useId, useState } from "react"
import {
Button,
Dialog,
DialogTrigger,
Modal,
Popover,
} from "react-aria-components"
import { useCallback, useEffect, useRef, useState } from "react"
import { FocusScope, useOverlay } from "react-aria"
import { Button } 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"
@@ -26,107 +23,175 @@ export default function GuestsRoomsPickerForm({
}: {
ariaLabelledBy?: string
}) {
const { trigger } = useFormContext<BookingWidgetSchema>()
const rooms = useWatch<BookingWidgetSchema, "rooms">({ name: "rooms" })
const popoverId = useId()
const checkIsDesktop = useMediaQuery("(min-width: 1367px)")
const [isDesktop, setIsDesktop] = useState(true)
const [isOpen, setIsOpen] = useState(false)
const [containerHeight, setContainerHeight] = useState(0)
const childCount = rooms[0]?.childrenInRoom.length ?? 0 // ToDo Update for multiroom later
const [containerConstraint, setContainerConstraint] = useState(0)
const [showErrorModal, setShowErrorModal] = useState(false)
//isOpen is the 'old state', so isOpen === true means "The modal is open and WILL be closed".
async function setOverflowClip(isOpen: boolean) {
const bodyElement = document.body
if (bodyElement) {
if (isOpen) {
bodyElement.style.overflow = "visible"
} else {
// !important needed to override 'overflow: hidden' set by react-aria.
// 'overflow: hidden' does not work in combination with other sticky positioned elements, which clip does.
bodyElement.style.overflow = "clip !important"
}
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)
}
if (!isOpen) {
const state = await trigger("rooms")
if (state) {
setIsOpen(isOpen)
}
}, [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(() => {
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 = document
.getElementsByClassName(popoverId)[0]
?.getBoundingClientRect().height
if (
maxHeight != containerHeight &&
const innerContainerHeight = popoverElement?.getBoundingClientRect().height
const shouldAdjustHeight = Boolean(
// height should be constrained
maxHeight != containerConstraint &&
innerContainerHeight &&
maxHeight <= innerContainerHeight
) {
setContainerHeight(maxHeight)
} else if (
containerHeight &&
)
const hasExcessVerticalSpace = Boolean(
// no need to constrain height
containerConstraint &&
innerContainerHeight &&
maxHeight > innerContainerHeight
) {
setContainerHeight(0)
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)
}
}, [containerHeight, popoverId])
}, [])
useEffect(() => {
if (isDesktop && rooms.length > 0) {
updateHeight()
if (isDesktop && rooms.length > 1) {
updateHeight(containerConstraint)
}
}, [childCount, isDesktop, updateHeight, rooms])
}, [
isOpen,
scrollPosition,
isDesktop,
updateHeight,
containerConstraint,
rooms,
])
return isDesktop ? (
<DialogTrigger onOpenChange={setOverflowClip} isOpen={isOpen}>
<Trigger
rooms={rooms}
className={styles.triggerDesktop}
triggerFn={() => {
setIsOpen(true)
}}
/>
<Popover
className={popoverId}
placement="bottom start"
offset={36}
style={containerHeight ? { overflow: "auto" } : undefined}
>
<Dialog className={styles.pickerContainerDesktop}>
{({ close }) => <PickerForm rooms={rooms} onClose={close} />}
</Dialog>
</Popover>
</DialogTrigger>
) : (
<DialogTrigger onOpenChange={setOverflowClip} isOpen={isOpen}>
<Trigger
rooms={rooms}
className={styles.triggerMobile}
triggerFn={() => {
setIsOpen(true)
}}
ariaLabelledBy={ariaLabelledBy}
/>
<Modal>
<Dialog className={styles.pickerContainerMobile}>
{({ close }) => <PickerForm rooms={rooms} onClose={close} />}
</Dialog>
</Modal>
</DialogTrigger>
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}
className={styles.trigger}
triggerFn={() => {
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 />}
</>
)
}