-
-
-
+
+
+
+ {errors.search && }
+
+
+
+
+ {nights > 0
+ ? intl.formatMessage(
+ {
+ id: "booking.numberOfNights",
+ defaultMessage:
+ "{totalNights, plural, one {# night} other {# nights}}",
+ },
+ { totalNights: nights }
+ )
+ : intl.formatMessage({
+ id: "bookingWidget.label.checkIn",
+ defaultMessage: "Check in",
+ })}
+
+
+
+
+
+
+
+ {intl.formatMessage({
+ id: "bookingWidget.label.roomsAndGuests",
+ defaultMessage: "Rooms & Guests",
+ })}
+
+
+
+
+
+
+
- {errors.search && }
-
-
-
- {nights > 0
- ? intl.formatMessage(
- {
- id: "booking.numberOfNights",
- defaultMessage:
- "{totalNights, plural, one {# night} other {# nights}}",
- },
- { totalNights: nights }
- )
- : intl.formatMessage({
- id: "bookingWidget.label.checkIn",
- defaultMessage: "Check in",
- })}
-
-
-
-
-
-
-
- {intl.formatMessage({
- id: "bookingWidget.label.roomsAndGuests",
- defaultMessage: "Rooms & Guests",
- })}
-
-
-
-
-
-
-
-
-
-
- {intl.formatMessage({
- id: "datePicker.selectDates",
- defaultMessage: "Select dates",
- })}
-
-
+
+ {intl.formatMessage({
+ id: "datePicker.selectDates",
+ defaultMessage: "Select dates",
+ })}
>
diff --git a/packages/booking-flow/lib/components/BookingWidget/DatePicker/Range/Mobile.tsx b/packages/booking-flow/lib/components/BookingWidget/DatePicker/Range/Mobile.tsx
index fbd7ea128..e65f84922 100644
--- a/packages/booking-flow/lib/components/BookingWidget/DatePicker/Range/Mobile.tsx
+++ b/packages/booking-flow/lib/components/BookingWidget/DatePicker/Range/Mobile.tsx
@@ -5,8 +5,8 @@ import { useIntl } from "react-intl"
import { Lang } from "@scandic-hotels/common/constants/language"
import { dt } from "@scandic-hotels/common/dt"
+import { Button } from "@scandic-hotels/design-system/Button"
import { MaterialIcon } from "@scandic-hotels/design-system/Icons/MaterialIcon"
-import { OldDSButton as Button } from "@scandic-hotels/design-system/OldDSButton"
import { Typography } from "@scandic-hotels/design-system/Typography"
import useLang from "../../../../hooks/useLang"
@@ -131,19 +131,15 @@ export default function DatePickerRangeMobile({
-
-
- {intl.formatMessage({
- id: "datePicker.selectDates",
- defaultMessage: "Select dates",
- })}
-
-
+ {intl.formatMessage({
+ id: "datePicker.selectDates",
+ defaultMessage: "Select dates",
+ })}
diff --git a/packages/booking-flow/lib/components/BookingWidget/DatePicker/Range/desktop.module.css b/packages/booking-flow/lib/components/BookingWidget/DatePicker/Range/desktop.module.css
index ce9876ea4..4e842f356 100644
--- a/packages/booking-flow/lib/components/BookingWidget/DatePicker/Range/desktop.module.css
+++ b/packages/booking-flow/lib/components/BookingWidget/DatePicker/Range/desktop.module.css
@@ -14,6 +14,7 @@ div.months {
.captionLabel {
text-transform: capitalize;
+ color: var(--Text-Default);
}
td.day,
diff --git a/packages/booking-flow/lib/components/BookingWidget/DatePicker/Range/mobile.module.css b/packages/booking-flow/lib/components/BookingWidget/DatePicker/Range/mobile.module.css
index c9a27ee31..7d00efcb4 100644
--- a/packages/booking-flow/lib/components/BookingWidget/DatePicker/Range/mobile.module.css
+++ b/packages/booking-flow/lib/components/BookingWidget/DatePicker/Range/mobile.module.css
@@ -24,7 +24,7 @@
align-self: flex-end;
background-color: var(--Main-Grey-White);
grid-area: header;
- padding: var(--Space-x3) var(--Space-x2);
+ padding: 0 var(--Space-x2) 0;
position: sticky;
top: 0;
z-index: 10;
diff --git a/packages/booking-flow/lib/components/BookingWidget/DatePicker/date-picker.module.css b/packages/booking-flow/lib/components/BookingWidget/DatePicker/date-picker.module.css
index 80a85c665..ebd51ec0c 100644
--- a/packages/booking-flow/lib/components/BookingWidget/DatePicker/date-picker.module.css
+++ b/packages/booking-flow/lib/components/BookingWidget/DatePicker/date-picker.module.css
@@ -1,6 +1,7 @@
-.btn {
+.triggerButton {
background: none;
border: none;
+ color: var(--Text-Default);
cursor: pointer;
outline: none;
padding: 0;
@@ -12,55 +13,63 @@
bottom: 0;
right: 0;
padding: 20px var(--Space-x15) 0;
+ border-radius: var(--Corner-radius-lg);
}
-.body {
- color: var(--Text-Default);
-}
-
-.hideWrapper {
- background-color: var(--Main-Grey-White);
- display: none;
-}
-
-.container[data-datepicker-open="true"] .hideWrapper {
+.datePicker[data-datepicker-open="true"] {
display: block;
}
+.pickerContainer {
+ --header-height: 72px;
+ --sticky-button-height: 140px;
+ background-color: var(--Main-Grey-White);
+ border-radius: var(--Corner-radius-lg) var(--Corner-radius-lg) 0 0;
+ position: fixed;
+ left: 0;
+ right: 0;
+ bottom: 0;
+ top: calc(max(var(--sitewide-alert-sticky-height), 20px));
+ transition: top 300ms ease;
+ overflow: scroll;
+ z-index: var(--booking-widget-z-index);
+}
+
@media screen and (max-width: 1366px) {
- .container {
- z-index: 10001;
+ .datePicker {
height: 24px;
}
- .hideWrapper {
- bottom: 0;
- left: 0;
- overflow: hidden;
- position: fixed;
- right: 0;
- top: 100%;
- transition: top 300ms ease;
- z-index: 10001;
- }
-
- .container[data-datepicker-open="true"] .hideWrapper {
+ .datePicker[data-datepicker-open="true"] {
border-radius: var(--Corner-radius-lg) var(--Corner-radius-lg) 0 0;
top: calc(max(var(--sitewide-alert-sticky-height), 20px));
}
}
@media screen and (min-width: 1367px) {
- .hideWrapper {
+ .datePicker {
+ display: block;
+ }
+
+ .pickerContainer {
+ position: absolute;
+ display: grid;
border-radius: var(--Corner-radius-lg);
box-shadow: var(--popup-box-shadow);
padding: var(--Space-x2) var(--Space-x3);
- position: absolute;
- /**
- BookingWidget padding +
- border-width +
- wanted space below booking widget
- */
- top: calc(100% + var(--Space-x1) + 1px + var(--Space-x4));
+ max-width: calc(100vw - 20px);
+ max-height: 440px;
+ top: calc(100% + 36px);
+ left: auto;
+ right: auto;
+ bottom: auto;
+ overflow: visible;
+ }
+
+ .triggerButton {
+ display: block;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ border-radius: var(--Corner-radius-md);
}
}
diff --git a/packages/booking-flow/lib/components/BookingWidget/DatePicker/index.tsx b/packages/booking-flow/lib/components/BookingWidget/DatePicker/index.tsx
index 304f4764d..1f5030dac 100644
--- a/packages/booking-flow/lib/components/BookingWidget/DatePicker/index.tsx
+++ b/packages/booking-flow/lib/components/BookingWidget/DatePicker/index.tsx
@@ -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
(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 (
- {
- closeOnBlur(e.nativeEvent)
- }}
- data-datepicker-open={isOpen}
- ref={ref}
- >
-
setIsOpen(true)}
- type="button"
- >
-
-
- {intl.formatMessage(
- {
- id: "booking.selectedDateRange",
- defaultMessage: "{selectedFromDate} – {selectedToDate}",
- },
- {
- selectedFromDate,
- selectedToDate,
- }
- )}
-
-
-
-
-
-
-
- {isOpen && (
-
- )}
-
+ return isDesktop ? (
+
+
{
+ setIsOpen((prev) => !prev)
+ }}
+ selectedFromDate={selectedFromDate}
+ selectedToDate={selectedToDate}
+ />
+ {isOpen && (
+
+ )}
+
+ ) : (
+
+
{
+ setIsOpen((prev) => !prev)
+ if (!isOpen) {
+ lockScroll()
+ } else {
+ unlockScroll()
+ }
+ }}
+ selectedFromDate={selectedFromDate}
+ selectedToDate={selectedToDate}
+ />
+ {isOpen && (
+
+ )}
)
}
+
+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 (
+ <>
+
+
+ {triggerText}
+
+
+
+
+ >
+ )
+}
diff --git a/packages/booking-flow/lib/components/BookingWidget/GuestsRoomsPicker/ChildSelector/ChildInfoSelector.tsx b/packages/booking-flow/lib/components/BookingWidget/GuestsRoomsPicker/ChildSelector/ChildInfoSelector.tsx
index 110b0774c..2623ad0f7 100644
--- a/packages/booking-flow/lib/components/BookingWidget/GuestsRoomsPicker/ChildSelector/ChildInfoSelector.tsx
+++ b/packages/booking-flow/lib/components/BookingWidget/GuestsRoomsPicker/ChildSelector/ChildInfoSelector.tsx
@@ -1,11 +1,12 @@
"use client"
+import { useEffect, useRef, useState } from "react"
import { useFormContext } from "react-hook-form"
import { useIntl } from "react-intl"
-import Caption from "@scandic-hotels/design-system/Caption"
-import DeprecatedSelect from "@scandic-hotels/design-system/DeprecatedSelect"
import { MaterialIcon } from "@scandic-hotels/design-system/Icons/MaterialIcon"
+import { Select } from "@scandic-hotels/design-system/Select"
+import { Typography } from "@scandic-hotels/design-system/Typography"
import { ChildBedMapEnum } from "@scandic-hotels/trpc/enums/childBedMapEnum"
import styles from "./child-selector.module.css"
@@ -38,6 +39,34 @@ export default function ChildInfoSelector({
index = 0,
roomIndex = 0,
}: ChildInfoSelectorProps) {
+ const ageSelectRef = useRef
(null)
+ const bedPrefSelectRef = useRef(null)
+ const [ageWidth, setAgeWidth] = useState(undefined)
+ const [bedWidth, setBedWidth] = useState(undefined)
+
+ //Match width of the dropdown with width of parent select
+ useEffect(() => {
+ if (!ageSelectRef.current) return
+
+ const observer = new ResizeObserver(() => {
+ setAgeWidth(ageSelectRef.current!.offsetWidth)
+ })
+
+ observer.observe(ageSelectRef.current)
+ return () => observer.disconnect()
+ }, [])
+
+ useEffect(() => {
+ if (!bedPrefSelectRef.current) return
+
+ const observer = new ResizeObserver(() => {
+ setBedWidth(bedPrefSelectRef.current!.offsetWidth)
+ })
+
+ observer.observe(bedPrefSelectRef.current)
+ return () => observer.disconnect()
+ }, [])
+
const ageFieldName = `rooms.${roomIndex}.childrenInRoom.${index}.age`
const bedFieldName = `rooms.${roomIndex}.childrenInRoom.${index}.bed`
const intl = useIntl()
@@ -49,10 +78,12 @@ export default function ChildInfoSelector({
id: "booking.bedPreference",
defaultMessage: "Bed preference",
})
+
const errorMessage = intl.formatMessage({
id: "bookingWidget.child.ageRequiredError",
defaultMessage: "Child age is required",
})
+
const { setValue, formState } = useFormContext()
function updateSelectedBed(bed: number) {
@@ -113,50 +144,62 @@ export default function ChildInfoSelector({
return (
<>
-
-
+ {
+ onSelectionChange={(key) => {
updateSelectedAge(key as number)
}}
- maxHeight={180}
- name={ageFieldName}
- isNestedInModal={true}
+ popoverWidth={`${ageWidth}px`}
+ value={child.age ?? childDefaultValues.age}
+ isInvalid={!!ageError}
/>
-
+
{child.age >= 0 ? (
- {
+ onSelectionChange={(key) => {
updateSelectedBed(key as number)
}}
- name={bedFieldName}
- isNestedInModal={true}
+ popoverWidth={`${bedWidth}px`}
/>
) : null}
{roomErrors && roomErrors.message ? (
-
-
- {roomErrors.message}
-
+
+
+
+ {roomErrors.message}
+
+
) : null}
{ageError || bedError ? (
-
-
- {errorMessage}
-
+ <>
+
+
+
+ {errorMessage}
+
+
+ >
) : null}
>
)
diff --git a/packages/booking-flow/lib/components/BookingWidget/GuestsRoomsPicker/ChildSelector/child-selector.module.css b/packages/booking-flow/lib/components/BookingWidget/GuestsRoomsPicker/ChildSelector/child-selector.module.css
index f4ba9d1b2..7fe69c933 100644
--- a/packages/booking-flow/lib/components/BookingWidget/GuestsRoomsPicker/ChildSelector/child-selector.module.css
+++ b/packages/booking-flow/lib/components/BookingWidget/GuestsRoomsPicker/ChildSelector/child-selector.module.css
@@ -15,6 +15,7 @@
}
.error {
+ color: var(--Text-Interactive-Error);
display: flex;
align-items: center;
gap: var(--Space-x1);
diff --git a/packages/booking-flow/lib/components/BookingWidget/GuestsRoomsPicker/Form.tsx b/packages/booking-flow/lib/components/BookingWidget/GuestsRoomsPicker/Form.tsx
index 22509e60f..72016c3a1 100644
--- a/packages/booking-flow/lib/components/BookingWidget/GuestsRoomsPicker/Form.tsx
+++ b/packages/booking-flow/lib/components/BookingWidget/GuestsRoomsPicker/Form.tsx
@@ -6,11 +6,11 @@ import { useIntl } from "react-intl"
import { Button } from "@scandic-hotels/design-system/Button"
import { MaterialIcon } from "@scandic-hotels/design-system/Icons/MaterialIcon"
-import { Tooltip } from "@scandic-hotels/design-system/Tooltip"
import { Typography } from "@scandic-hotels/design-system/Typography"
import { SEARCH_TYPE_REDEMPTION } from "@scandic-hotels/trpc/constants/booking"
import { useBookingFlowConfig } from "../../../bookingFlowConfig/bookingFlowConfigContext"
+import { useIsDesktop } from "../../../hooks/useBreakpoint"
import { GuestsRoom } from "./GuestsRoom"
import styles from "./guests-rooms-picker.module.css"
@@ -30,6 +30,7 @@ export default function GuestsRoomsPickerDialog({
onClose,
}: GuestsRoomsPickerDialogProps) {
const intl = useIntl()
+ const isDesktop = useIsDesktop()
const config = useBookingFlowConfig()
const { getFieldState, trigger, setValue, getValues } =
useFormContext
()
@@ -61,6 +62,11 @@ export default function GuestsRoomsPickerDialog({
defaultMessage:
"Multi-room booking is not available with this booking code.",
})
+ const isInvalid =
+ getFieldState("rooms").invalid ||
+ roomsValue.some((room) =>
+ room.childrenInRoom.some((child) => child.age === undefined)
+ )
const handleClose = useCallback(async () => {
const isValid = await trigger("rooms")
@@ -97,13 +103,11 @@ export default function GuestsRoomsPickerDialog({
if (fieldState.invalid) trigger("rooms")
}, [roomsValue, getFieldState, trigger])
- const isInvalid =
- getFieldState("rooms").invalid ||
- roomsValue.some((room) =>
- room.childrenInRoom.some((child) => child.age === undefined)
- )
const canAddRooms = rooms.length < MAX_ROOMS
+ const addRoomButtonDisabled =
+ !!addRoomDisabledTextForSpecialRate || !canAddRooms
+
return (
<>
@@ -122,114 +126,89 @@ export default function GuestsRoomsPickerDialog({
onRemove={handleRemoveRoom}
/>
))}
-
- {addRoomDisabledTextForSpecialRate ? (
-
-
-
- {addRoomLabel}
-
-
-
-
-
- {addRoomDisabledTextForSpecialRate}
-
-
-
-
- ) : (
- canAddRooms && (
-
+ {!isDesktop && (
+ <>
+
{addRoomLabel}
- )
+
+ {addRoomDisabledTextForSpecialRate && (
+
+
+
+
+ {addRoomDisabledTextForSpecialRate}
+
+
+
+ )}
+ >
)}
- {addRoomDisabledTextForSpecialRate ? (
-
-
+ {isDesktop && (
+
-
-
- {addRoomLabel}
-
-
-
- ) : (
- canAddRooms && (
-
-
-
- {addRoomLabel}
-
+
+ {addRoomLabel}
+
+ )}
+
+ {doneLabel}
+
+
+
+ {/* DESKTOP INLINE ERROR MESSAGE */}
+ {addRoomDisabledTextForSpecialRate && isDesktop && (
+
+
+
+ {addRoomDisabledTextForSpecialRate}
- )
+
)}
-
- {doneLabel}
-
-
- {doneLabel}
-
>
)
diff --git a/packages/booking-flow/lib/components/BookingWidget/GuestsRoomsPicker/ValidationError/index.tsx b/packages/booking-flow/lib/components/BookingWidget/GuestsRoomsPicker/ValidationError/index.tsx
new file mode 100644
index 000000000..5de9ff649
--- /dev/null
+++ b/packages/booking-flow/lib/components/BookingWidget/GuestsRoomsPicker/ValidationError/index.tsx
@@ -0,0 +1,33 @@
+import { useIntl } from "react-intl"
+
+import { MaterialIcon } from "@scandic-hotels/design-system/Icons/MaterialIcon"
+import { Typography } from "@scandic-hotels/design-system/Typography"
+
+import styles from "./validationError.module.css"
+
+export default function ValidationError() {
+ const intl = useIntl()
+
+ const errorMessage = intl.formatMessage({
+ id: "bookingWidget.child.ageRequiredError",
+ defaultMessage: "Child age is required",
+ })
+
+ return (
+
+
+
+
+ {errorMessage}
+
+
+
+ )
+}
diff --git a/packages/booking-flow/lib/components/BookingWidget/GuestsRoomsPicker/ValidationError/validationError.module.css b/packages/booking-flow/lib/components/BookingWidget/GuestsRoomsPicker/ValidationError/validationError.module.css
new file mode 100644
index 000000000..307625ff4
--- /dev/null
+++ b/packages/booking-flow/lib/components/BookingWidget/GuestsRoomsPicker/ValidationError/validationError.module.css
@@ -0,0 +1,38 @@
+.container {
+ position: absolute;
+ top: calc(100% + var(--Space-x2));
+ background: var(--Surface-Primary-Default);
+ border-radius: var(--Corner-radius-lg);
+ box-shadow: 0px 0px 14px 6px rgba(0, 0, 0, 0.1);
+ padding: var(--Space-x15);
+ max-width: min(100vw, calc(360px - var(--Space-x2)));
+ width: 100%;
+ left: 0;
+ display: flex;
+ flex-direction: column;
+ padding: var(--Space-x15);
+ align-items: flex-start;
+ gap: var(--Space-x05);
+ z-index: var(--dialog-z-index);
+}
+
+.title {
+ display: flex;
+ align-items: center;
+ gap: var(--Space-x1);
+ color: var(--UI-Text-Error);
+}
+
+.message {
+ text-wrap: auto;
+}
+
+@media screen and (min-width: 1367px) {
+ .container {
+ top: calc(100% + var(--Space-x1) + var(--Space-x2));
+ left: calc(var(--Space-x1) * -1);
+ padding: var(--Space-x2);
+ max-width: 360px;
+ width: fit-content;
+ }
+}
diff --git a/packages/booking-flow/lib/components/BookingWidget/GuestsRoomsPicker/guests-rooms-picker.module.css b/packages/booking-flow/lib/components/BookingWidget/GuestsRoomsPicker/guests-rooms-picker.module.css
index d6b537910..ee30c8463 100644
--- a/packages/booking-flow/lib/components/BookingWidget/GuestsRoomsPicker/guests-rooms-picker.module.css
+++ b/packages/booking-flow/lib/components/BookingWidget/GuestsRoomsPicker/guests-rooms-picker.module.css
@@ -1,30 +1,17 @@
-.triggerDesktop {
- display: none;
-}
-
.errorContainer {
display: flex;
- padding: var(--Space-x2);
+ padding: var(--Space-x15);
+ border: 1px solid var(--Border-Default);
+ border-radius: var(--Corner-radius-md);
+ justify-content: center;
}
.error {
display: flex;
gap: var(--Space-x1);
- color: var(--UI-Text-Error);
-}
-
-.pickerContainerMobile {
- --header-height: 72px;
- --sticky-button-height: 140px;
- background-color: var(--Main-Grey-White);
- border-radius: var(--Corner-radius-lg) var(--Corner-radius-lg) 0 0;
- bottom: 0;
- left: 0;
- position: fixed;
- right: 0;
- top: calc(max(var(--sitewide-alert-sticky-height), 20px));
- transition: top 300ms ease;
- z-index: 100;
+ color: var(--Text-Feedback-Error);
+ text-wrap: wrap;
+ align-items: center;
}
.contentWrapper {
@@ -35,10 +22,6 @@
grid-template-rows: var(--header-height) calc(100dvh - var(--header-height));
}
-.pickerContainerDesktop {
- display: none;
-}
-
.roomContainer {
display: grid;
gap: var(--Space-x2);
@@ -69,6 +52,7 @@
bottom: 0;
right: 0;
padding: 20px var(--Space-x15) 0;
+ border-radius: var(--Corner-radius-lg);
}
.guestsAndRooms {
@@ -77,10 +61,18 @@
.footer {
display: flex;
- flex-direction: row;
- gap: var(--Space-x1);
+ flex-direction: column;
+ gap: var(--Space-x2);
}
+.addRoomBtnContainer {
+ display: flex;
+ justify-content: center;
+}
+.footerButtons {
+ display: flex;
+ justify-content: space-between;
+}
.roomContainer {
padding: var(--Space-x2);
}
@@ -97,15 +89,27 @@
width: 100%;
}
-.contentWrapper
- .addRoomMobileContainer
- .addRoomBtn:is(:focus, :focus-visible, :focus-within),
-.footer .hideOnMobile .addRoomBtn:is(:focus, :focus-visible, :focus-within),
+.contentWrapper .addRoomBtn:is(:focus, :focus-visible, :focus-within),
+.footer .addRoomBtn:is(:focus, :focus-visible, :focus-within),
.roomActionsButton:is(:focus, :focus-visible, :focus-within) {
- outline: var(--Border-Interactive-Focus) auto 1px;
text-decoration: none;
}
+.pickerContainer {
+ --header-height: 72px;
+ --sticky-button-height: 140px;
+ background-color: var(--Main-Grey-White);
+ border-radius: var(--Corner-radius-lg) var(--Corner-radius-lg) 0 0;
+ position: fixed;
+ top: calc(max(var(--sitewide-alert-sticky-height), 20px));
+ right: 0;
+ bottom: 0;
+ left: 0;
+ transition: top 300ms ease;
+ z-index: var(--booking-widget-z-index);
+ overflow: scroll;
+}
+
@media screen and (max-width: 1366px) {
.contentContainer {
grid-area: content;
@@ -140,43 +144,52 @@
width: 100%;
}
- .footer .hideOnMobile {
- display: none;
- }
-
.addRoomMobileContainer {
display: grid;
- padding-bottom: calc(var(--sticky-button-height) + 20px);
+ padding-bottom: var(--Space-x3);
+ }
+ .errorContainer {
+ margin: var(--Space-x2);
}
-
.addRoomMobileContainer button {
width: 150px;
margin: 0 auto;
}
-
- .addRoomMobileContainer .addRoomMobileDisabledText {
- padding: var(--Space-x1) var(--Space-x2);
- background-color: var(--Background-Primary);
- margin: 0 var(--Space-x2);
- border-radius: var(--Corner-radius-md);
- display: flex;
- gap: var(--Space-x1);
- }
}
@media screen and (min-width: 1367px) {
.container {
height: 24px;
}
-
- .pickerContainerMobile {
- display: none;
- }
-
.contentWrapper {
grid-template-rows: auto;
}
+ .footerButtons {
+ max-height: 40px;
+ }
+
+ .doneButton {
+ min-width: 125px;
+ }
+
+ .pickerContainer {
+ position: absolute;
+ display: grid;
+ bottom: auto;
+ left: auto;
+ right: auto;
+ border-radius: var(--Corner-radius-lg);
+ box-shadow: var(--popup-box-shadow);
+ min-width: 360px;
+ max-width: calc(100vw - 20px);
+ padding: var(--Space-x2) var(--Space-x3);
+ top: calc(100% + 36px);
+ max-height: none;
+ overflow-y: auto;
+ overflow-x: hidden;
+ }
+
.roomContainer {
padding: var(--Space-x2) 0 0 0;
}
@@ -193,34 +206,13 @@
overflow-y: visible;
}
- .triggerMobile {
- display: none;
- }
-
- .triggerDesktop {
- display: block;
- }
-
- .triggerDesktop > span {
+ .trigger > span {
display: block;
overflow: hidden;
text-overflow: ellipsis;
}
- .pickerContainerDesktop {
- --header-height: 72px;
- --sticky-button-height: 140px;
-
- background-color: var(--Main-Grey-White);
- display: grid;
- border-radius: var(--Corner-radius-lg);
- box-shadow: var(--popup-box-shadow);
- max-width: calc(100vw - 20px);
- padding: var(--Space-x2) var(--Space-x3);
- width: 360px;
- }
-
- .pickerContainerDesktop:focus-visible {
+ .pickerContainer:focus-visible {
outline: none;
}
@@ -229,18 +221,13 @@
}
.footer {
- grid-template-columns: auto auto;
+ display: flex;
+ justify-content: space-between;
padding-top: var(--Space-x2);
+ height: fit-content;
}
.footer button {
- margin-left: auto;
width: auto;
- min-width: 125px;
- }
-
- .footer .hideOnDesktop,
- .addRoomMobileContainer {
- display: none;
}
}
diff --git a/packages/booking-flow/lib/components/BookingWidget/GuestsRoomsPicker/index.tsx b/packages/booking-flow/lib/components/BookingWidget/GuestsRoomsPicker/index.tsx
index bcc5b6c11..cf5790c74 100644
--- a/packages/booking-flow/lib/components/BookingWidget/GuestsRoomsPicker/index.tsx
+++ b/packages/booking-flow/lib/components/BookingWidget/GuestsRoomsPicker/index.tsx
@@ -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()
const rooms = useWatch({ 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(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(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 ? (
-
- {
- setIsOpen(true)
- }}
- />
-
-
- {({ close }) => }
-
-
-
- ) : (
-
- {
- setIsOpen(true)
- }}
- ariaLabelledBy={ariaLabelledBy}
- />
-
-
- {({ close }) => }
-
-
-
+ 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 (
+ <>
+
+
{
+ setIsOpen((prev) => !prev)
+ if (!isDesktop && !isOpen) {
+ lockScroll()
+ } else {
+ unlockScroll()
+ }
+ }}
+ ariaLabelledBy={ariaLabelledBy}
+ />
+
+ {isOpen && (
+
+
+ 0
+ ? { maxHeight: containerConstraint }
+ : { maxHeight: "none" }
+ : undefined
+ }
+ >
+
{
+ setIsOpen((prev) => !prev)
+ unlockScroll()
+ }}
+ />
+
+
+
+ )}
+
+ {showErrorModal && !isOpen && errors.rooms && }
+ >
)
}
diff --git a/packages/booking-flow/lib/components/BookingWidget/bookingWidget.module.css b/packages/booking-flow/lib/components/BookingWidget/bookingWidget.module.css
index 5fecdfa6b..73e2eb561 100644
--- a/packages/booking-flow/lib/components/BookingWidget/bookingWidget.module.css
+++ b/packages/booking-flow/lib/components/BookingWidget/bookingWidget.module.css
@@ -24,7 +24,8 @@
}
/* Make sure Date Picker is placed on top of other sticky/fixed components */
- &:has([data-datepicker-open="true"]) {
+ &:has([data-datepicker-open="true"]),
+ &:has([data-rooms-open="true"]) {
z-index: var(--booking-widget-open-z-index);
}
}
@@ -37,7 +38,7 @@
gap: var(--Space-x3);
height: calc(100dvh - max(var(--sitewide-alert-sticky-height), 20px));
width: 100%;
- padding: var(--Space-x3) var(--Space-x2) var(--Space-x7);
+ padding: var(--Space-x3) var(--Space-x2);
position: fixed;
left: 0;
bottom: -100%;
@@ -85,7 +86,6 @@
&.compactFormContainer {
box-shadow: none;
- padding-left: var(--Space-x15);
}
}
diff --git a/packages/booking-flow/package.json b/packages/booking-flow/package.json
index 3c397d6d1..5cd08d244 100644
--- a/packages/booking-flow/package.json
+++ b/packages/booking-flow/package.json
@@ -64,6 +64,7 @@
"libphonenumber-js": "^1.12.15",
"motion": "^12.10.0",
"nuqs": "2.4.3",
+ "react-aria": "^3.39.0",
"react-aria-components": "1.8.0",
"react-day-picker": "^9.6.7",
"react-hook-form": "^7.56.2",
diff --git a/packages/design-system/lib/components/Button/button.module.css b/packages/design-system/lib/components/Button/button.module.css
index 34f4f9390..ad8731ade 100644
--- a/packages/design-system/lib/components/Button/button.module.css
+++ b/packages/design-system/lib/components/Button/button.module.css
@@ -14,7 +14,7 @@
}
&[data-disabled] {
- cursor: unset;
+ cursor: not-allowed;
}
&[data-pending] {
diff --git a/packages/design-system/lib/components/Form/Checkbox/index.tsx b/packages/design-system/lib/components/Form/Checkbox/index.tsx
index afe82da72..1e78229c4 100644
--- a/packages/design-system/lib/components/Form/Checkbox/index.tsx
+++ b/packages/design-system/lib/components/Form/Checkbox/index.tsx
@@ -32,6 +32,7 @@ const CheckboxComponent = forwardRef<
hideError,
topAlign = false,
errorCodeMessages,
+ disabled = false,
},
ref
) {
@@ -49,7 +50,7 @@ const CheckboxComponent = forwardRef<
onChange={field.onChange}
data-testid={name}
name={name}
- isDisabled={registerOptions?.disabled}
+ isDisabled={registerOptions?.disabled || disabled}
excludeFromTabOrder
>
{({ isSelected }) => (
diff --git a/packages/design-system/lib/components/Select/Select.tsx b/packages/design-system/lib/components/Select/Select.tsx
index c57bb4fd3..e3a4b06cb 100644
--- a/packages/design-system/lib/components/Select/Select.tsx
+++ b/packages/design-system/lib/components/Select/Select.tsx
@@ -25,6 +25,7 @@ export function Select({
isDisabled,
icon,
itemIcon,
+ popoverWidth,
...props
}: SelectProps | SelectFilterProps) {
const [isOpen, setIsOpen] = useState(false)
@@ -88,7 +89,11 @@ export function Select({
/>
-
+
{items.map((item, idx) => (
{
label: string
onSelectionChange?: (key: Key | null) => void
enableFiltering?: false
+ popoverWidth?: string
}
export interface SelectItemProps extends ComponentProps {
@@ -35,4 +36,5 @@ export interface SelectFilterProps extends ComponentProps {
label: string
onSelectionChange?: (key: Key | null) => void
enableFiltering: true
+ popoverWidth?: string
}
diff --git a/yarn.lock b/yarn.lock
index 1a7501a71..22be45c53 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -6053,6 +6053,7 @@ __metadata:
libphonenumber-js: "npm:^1.12.15"
motion: "npm:^12.10.0"
nuqs: "npm:2.4.3"
+ react-aria: "npm:^3.39.0"
react-aria-components: "npm:1.8.0"
react-day-picker: "npm:^9.6.7"
react-hook-form: "npm:^7.56.2"