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:
@@ -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<HTMLDivElement>(null)
|
||||
const bedPrefSelectRef = useRef<HTMLDivElement>(null)
|
||||
const [ageWidth, setAgeWidth] = useState<number | undefined>(undefined)
|
||||
const [bedWidth, setBedWidth] = useState<number | undefined>(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 (
|
||||
<>
|
||||
<div key={index} className={styles.childInfoContainer}>
|
||||
<div>
|
||||
<DeprecatedSelect
|
||||
required={true}
|
||||
<div ref={ageSelectRef}>
|
||||
<Select
|
||||
isRequired
|
||||
items={ageList}
|
||||
name={ageFieldName}
|
||||
label={ageLabel}
|
||||
aria-label={ageLabel}
|
||||
value={child.age ?? childDefaultValues.age}
|
||||
onSelect={(key) => {
|
||||
onSelectionChange={(key) => {
|
||||
updateSelectedAge(key as number)
|
||||
}}
|
||||
maxHeight={180}
|
||||
name={ageFieldName}
|
||||
isNestedInModal={true}
|
||||
popoverWidth={`${ageWidth}px`}
|
||||
value={child.age ?? childDefaultValues.age}
|
||||
isInvalid={!!ageError}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<div ref={bedPrefSelectRef}>
|
||||
{child.age >= 0 ? (
|
||||
<DeprecatedSelect
|
||||
<Select
|
||||
isRequired
|
||||
items={getAvailableBeds(child.age)}
|
||||
name={bedFieldName}
|
||||
label={bedLabel}
|
||||
aria-label={bedLabel}
|
||||
value={child.bed ?? childDefaultValues.bed}
|
||||
onSelect={(key) => {
|
||||
onSelectionChange={(key) => {
|
||||
updateSelectedBed(key as number)
|
||||
}}
|
||||
name={bedFieldName}
|
||||
isNestedInModal={true}
|
||||
popoverWidth={`${bedWidth}px`}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{roomErrors && roomErrors.message ? (
|
||||
<Caption color="red" className={styles.error}>
|
||||
<MaterialIcon icon="error" color="Icon/Interactive/Accent" />
|
||||
{roomErrors.message}
|
||||
</Caption>
|
||||
<Typography
|
||||
variant="Body/Supporting text (caption)/smRegular"
|
||||
className={styles.error}
|
||||
>
|
||||
<span>
|
||||
<MaterialIcon icon="error" color="Icon/Interactive/Accent" />
|
||||
{roomErrors.message}
|
||||
</span>
|
||||
</Typography>
|
||||
) : null}
|
||||
|
||||
{ageError || bedError ? (
|
||||
<Caption color="red" className={styles.error}>
|
||||
<MaterialIcon icon="error" color="Icon/Interactive/Accent" />
|
||||
{errorMessage}
|
||||
</Caption>
|
||||
<>
|
||||
<Typography
|
||||
variant="Body/Supporting text (caption)/smRegular"
|
||||
className={styles.error}
|
||||
>
|
||||
<span>
|
||||
<MaterialIcon icon="error" color="Icon/Feedback/Error" />
|
||||
{errorMessage}
|
||||
</span>
|
||||
</Typography>
|
||||
</>
|
||||
) : null}
|
||||
</>
|
||||
)
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
}
|
||||
|
||||
.error {
|
||||
color: var(--Text-Interactive-Error);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--Space-x1);
|
||||
|
||||
@@ -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<BookingWidgetSchema>()
|
||||
@@ -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 (
|
||||
<>
|
||||
<section className={styles.contentWrapper}>
|
||||
@@ -122,114 +126,89 @@ export default function GuestsRoomsPickerDialog({
|
||||
onRemove={handleRemoveRoom}
|
||||
/>
|
||||
))}
|
||||
|
||||
{addRoomDisabledTextForSpecialRate ? (
|
||||
<div className={styles.addRoomMobileContainer}>
|
||||
<Button
|
||||
variant="Text"
|
||||
color="Primary"
|
||||
wrapping
|
||||
onPress={handleAddRoom}
|
||||
isDisabled
|
||||
size="sm"
|
||||
>
|
||||
<MaterialIcon icon="add" color="CurrentColor" />
|
||||
{addRoomLabel}
|
||||
</Button>
|
||||
<div className={styles.errorContainer}>
|
||||
<Typography
|
||||
className={styles.error}
|
||||
variant="Body/Supporting text (caption)/smRegular"
|
||||
>
|
||||
<span>
|
||||
<MaterialIcon
|
||||
icon="error"
|
||||
size={20}
|
||||
color="Icon/Feedback/Error"
|
||||
isFilled
|
||||
/>
|
||||
{addRoomDisabledTextForSpecialRate}
|
||||
</span>
|
||||
</Typography>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
canAddRooms && (
|
||||
<div className={styles.addRoomMobileContainer}>
|
||||
{!isDesktop && (
|
||||
<>
|
||||
<div className={styles.addRoomBtnContainer}>
|
||||
<Button
|
||||
className={styles.addRoomBtn}
|
||||
variant="Text"
|
||||
wrapping
|
||||
color="Primary"
|
||||
onPress={handleAddRoom}
|
||||
isDisabled={addRoomButtonDisabled}
|
||||
size="sm"
|
||||
>
|
||||
<MaterialIcon icon="add" color="CurrentColor" />
|
||||
{addRoomLabel}
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
|
||||
{addRoomDisabledTextForSpecialRate && (
|
||||
<div className={styles.errorContainer}>
|
||||
<Typography
|
||||
className={styles.error}
|
||||
variant="Body/Supporting text (caption)/smRegular"
|
||||
>
|
||||
<span>
|
||||
<MaterialIcon
|
||||
icon="error"
|
||||
size={20}
|
||||
color="Icon/Feedback/Error"
|
||||
isFilled
|
||||
/>
|
||||
{addRoomDisabledTextForSpecialRate}
|
||||
</span>
|
||||
</Typography>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
<footer className={styles.footer}>
|
||||
{addRoomDisabledTextForSpecialRate ? (
|
||||
<div className={styles.hideOnMobile}>
|
||||
<Tooltip
|
||||
text={addRoomDisabledTextForSpecialRate}
|
||||
position="bottom"
|
||||
arrow="left"
|
||||
<div className={styles.footerButtons}>
|
||||
{isDesktop && (
|
||||
<Button
|
||||
variant="Text"
|
||||
wrapping
|
||||
color="Primary"
|
||||
isDisabled={addRoomButtonDisabled}
|
||||
size="sm"
|
||||
onPress={handleAddRoom}
|
||||
>
|
||||
<Button
|
||||
variant="Text"
|
||||
wrapping
|
||||
color="Primary"
|
||||
isDisabled
|
||||
size="sm"
|
||||
onPress={handleAddRoom}
|
||||
>
|
||||
<MaterialIcon icon="add_circle" color="CurrentColor" />
|
||||
{addRoomLabel}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
) : (
|
||||
canAddRooms && (
|
||||
<div className={styles.hideOnMobile}>
|
||||
<Button
|
||||
className={styles.addRoomBtn}
|
||||
variant="Text"
|
||||
wrapping
|
||||
color="Primary"
|
||||
size="sm"
|
||||
onPress={handleAddRoom}
|
||||
>
|
||||
<MaterialIcon icon="add_circle" color="CurrentColor" />
|
||||
{addRoomLabel}
|
||||
</Button>
|
||||
<MaterialIcon icon="add_circle" color="CurrentColor" />
|
||||
{addRoomLabel}
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
onPress={handleClose}
|
||||
isDisabled={isInvalid}
|
||||
className={styles.doneButton}
|
||||
variant={isDesktop ? "Tertiary" : "Primary"}
|
||||
color="Primary"
|
||||
size={isDesktop ? "sm" : "md"}
|
||||
>
|
||||
{doneLabel}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* DESKTOP INLINE ERROR MESSAGE */}
|
||||
{addRoomDisabledTextForSpecialRate && isDesktop && (
|
||||
<Typography
|
||||
className={styles.error}
|
||||
variant="Body/Supporting text (caption)/smRegular"
|
||||
>
|
||||
<div className={styles.errorContainer}>
|
||||
<MaterialIcon
|
||||
icon="error"
|
||||
size={20}
|
||||
color="Icon/Feedback/Error"
|
||||
isFilled
|
||||
/>
|
||||
{addRoomDisabledTextForSpecialRate}
|
||||
</div>
|
||||
)
|
||||
</Typography>
|
||||
)}
|
||||
<Button
|
||||
onPress={handleClose}
|
||||
isDisabled={isInvalid}
|
||||
className={styles.hideOnDesktop}
|
||||
variant="Tertiary"
|
||||
color="Primary"
|
||||
size="sm"
|
||||
>
|
||||
{doneLabel}
|
||||
</Button>
|
||||
<Button
|
||||
onPress={handleClose}
|
||||
isDisabled={isInvalid}
|
||||
className={styles.hideOnMobile}
|
||||
variant="Tertiary"
|
||||
color="Primary"
|
||||
size="sm"
|
||||
>
|
||||
{doneLabel}
|
||||
</Button>
|
||||
</footer>
|
||||
</>
|
||||
)
|
||||
|
||||
@@ -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 (
|
||||
<div className={styles.container}>
|
||||
<Typography
|
||||
className={styles.title}
|
||||
variant="Body/Supporting text (caption)/smBold"
|
||||
>
|
||||
<span>
|
||||
<MaterialIcon
|
||||
icon="error_circle_rounded"
|
||||
color="Icon/Feedback/Error"
|
||||
size={20}
|
||||
/>
|
||||
{errorMessage}
|
||||
</span>
|
||||
</Typography>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 />}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user