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,10 +1,14 @@
"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 { longDateFormat } from "@scandic-hotels/common/constants/dateFormats"
import { dt } from "@scandic-hotels/common/dt"
import { useScrollLock } from "@scandic-hotels/common/hooks/useScrollLock"
import { Typography } from "@scandic-hotels/design-system/Typography"
import useLang from "../../../hooks/useLang"
@@ -16,17 +20,24 @@ import styles from "./date-picker.module.css"
import type { DateRange } from "react-day-picker"
type DatePickerFormProps = {
ariaLabelledBy?: string
name?: string
}
export default function DatePickerForm({ name = "date" }: DatePickerFormProps) {
export default function DatePickerForm({
ariaLabelledBy,
name = "date",
}: DatePickerFormProps) {
const lang = useLang()
const intl = useIntl()
const checkIsDesktop = useMediaQuery("(min-width: 1367px)")
const [isDesktop, setIsDesktop] = useState(true)
const { lockScroll, unlockScroll } = useScrollLock({
autoLock: false,
})
const [isOpen, setIsOpen] = useState(false)
const selectedDate = useWatch({ name })
const { register, setValue } = useFormContext()
const { setValue } = useFormContext()
const ref = useRef<HTMLDivElement | null>(null)
const close = useCallback(() => {
if (!selectedDate.toDate) {
setValue(
@@ -38,13 +49,21 @@ export default function DatePickerForm({ name = "date" }: DatePickerFormProps) {
{ shouldDirty: true }
)
}
setIsOpen(false)
}, [name, setValue, selectedDate])
unlockScroll()
}, [name, setValue, selectedDate, unlockScroll])
function showOnFocus() {
setIsOpen(true)
}
const { overlayProps, underlayProps } = useOverlay(
{
isOpen,
onClose: () => {
setIsOpen(false)
unlockScroll()
},
isDismissable: true,
},
ref
)
function handleSelectDate(
_nextRange: DateRange | undefined,
@@ -98,34 +117,9 @@ export default function DatePickerForm({ name = "date" }: DatePickerFormProps) {
}
}
const closeIfOutside = useCallback(
(target: HTMLElement) => {
if (ref.current && target && !ref.current.contains(target)) {
close()
}
},
[close, ref]
)
function closeOnBlur(evt: FocusEvent) {
if (isOpen) {
const target = evt.relatedTarget as HTMLElement
closeIfOutside(target)
}
}
useEffect(() => {
function handleClickOutside(evt: Event) {
if (isOpen) {
const target = evt.target as HTMLElement
closeIfOutside(target)
}
}
document.body.addEventListener("click", handleClickOutside)
return () => {
document.body.removeEventListener("click", handleClickOutside)
}
}, [closeIfOutside, isOpen])
setIsDesktop(checkIsDesktop)
}, [checkIsDesktop])
const selectedFromDate = dt(selectedDate.fromDate)
.locale(lang)
@@ -134,64 +128,122 @@ export default function DatePickerForm({ name = "date" }: DatePickerFormProps) {
? dt(selectedDate.toDate).locale(lang).format(longDateFormat[lang])
: ""
return (
<div
className={styles.container}
onBlur={(e) => {
closeOnBlur(e.nativeEvent)
}}
data-datepicker-open={isOpen}
ref={ref}
>
<button
className={styles.btn}
onFocus={showOnFocus}
onClick={() => setIsOpen(true)}
type="button"
>
<Typography variant="Body/Paragraph/mdRegular" className={styles.body}>
<span>
{intl.formatMessage(
{
id: "booking.selectedDateRange",
defaultMessage: "{selectedFromDate} {selectedToDate}",
},
{
selectedFromDate,
selectedToDate,
}
)}
</span>
</Typography>
</button>
<input {...register("date.fromDate")} type="hidden" />
<input {...register("date.toDate")} type="hidden" />
<div aria-modal className={styles.hideWrapper} role="dialog">
<DatePickerRangeDesktop
close={close}
handleOnSelect={handleSelectDate}
// DayPicker lib needs Daterange in form as below to show appropriate UI
selectedRange={{
from: dt(selectedDate.fromDate).toDate(),
to: selectedDate.toDate
? dt(selectedDate.toDate).toDate()
: undefined,
}}
/>
{isOpen && (
<DatePickerRangeMobile
close={close}
handleOnSelect={handleSelectDate}
// DayPicker lib needs Daterange in form as below to show appropriate UI
selectedRange={{
from: dt(selectedDate.fromDate).toDate(),
to: selectedDate.toDate
? dt(selectedDate.toDate).toDate()
: undefined,
}}
/>
)}
</div>
return isDesktop ? (
<div className={styles.datePicker}>
<Trigger
ariaLabelledBy={ariaLabelledBy}
onPress={() => {
setIsOpen((prev) => !prev)
}}
selectedFromDate={selectedFromDate}
selectedToDate={selectedToDate}
/>
{isOpen && (
<div {...underlayProps}>
<FocusScope contain restoreFocus autoFocus>
<div
{...overlayProps}
ref={ref}
className={styles.pickerContainer}
data-datepicker-open={isOpen}
>
<DatePickerRangeDesktop
close={close}
handleOnSelect={handleSelectDate}
// DayPicker lib needs Daterange in form as below to show appropriate UI
selectedRange={{
from: dt(selectedDate.fromDate).toDate(),
to: selectedDate.toDate
? dt(selectedDate.toDate).toDate()
: undefined,
}}
/>
</div>
</FocusScope>
</div>
)}
</div>
) : (
<div className={styles.datePicker}>
<Trigger
ariaLabelledBy={ariaLabelledBy}
onPress={() => {
setIsOpen((prev) => !prev)
if (!isOpen) {
lockScroll()
} else {
unlockScroll()
}
}}
selectedFromDate={selectedFromDate}
selectedToDate={selectedToDate}
/>
{isOpen && (
<div {...underlayProps}>
<FocusScope contain restoreFocus autoFocus>
<div
{...overlayProps}
ref={ref}
className={styles.pickerContainer}
data-datepicker-open={isOpen}
>
<DatePickerRangeMobile
close={close}
handleOnSelect={handleSelectDate}
// DayPicker lib needs Daterange in form as below to show appropriate UI
selectedRange={{
from: dt(selectedDate.fromDate).toDate(),
to: selectedDate.toDate
? dt(selectedDate.toDate).toDate()
: undefined,
}}
/>
</div>
</FocusScope>
</div>
)}
</div>
)
}
function Trigger({
onPress,
selectedFromDate,
selectedToDate,
ariaLabelledBy,
}: {
onPress?: () => void
selectedFromDate: string
selectedToDate: string
ariaLabelledBy?: string
}) {
const intl = useIntl()
const { register } = useFormContext()
const triggerText = intl.formatMessage(
{
id: "booking.selectedDateRange",
defaultMessage: "{selectedFromDate} {selectedToDate}",
},
{
selectedFromDate,
selectedToDate,
}
)
return (
<>
<Typography variant="Body/Paragraph/mdRegular">
<ButtonRAC
className={styles.triggerButton}
onPress={onPress}
type="button"
aria-labelledby={ariaLabelledBy}
>
{triggerText}
</ButtonRAC>
</Typography>
<input {...register("date.fromDate")} type="hidden" />
<input {...register("date.toDate")} type="hidden" />
</>
)
}