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

@@ -0,0 +1,28 @@
.errorContainer {
display: flex;
flex-direction: column;
gap: var(--Space-x2);
}
.error {
display: flex;
gap: var(--Space-x1);
text-wrap: wrap;
color: var(--UI-Text-Error);
}
.removeButton {
width: 100%;
}
@media screen and (min-width: 768px) {
.error {
color: var(--Text-Default);
}
}
@media screen and (max-width: 767px) {
.removeButton {
display: none;
}
}

View File

@@ -0,0 +1,48 @@
import { type FieldError } from "react-hook-form"
import { useIntl } from "react-intl"
import { MaterialIcon } from "@scandic-hotels/design-system/Icons/MaterialIcon"
import { Typography } from "@scandic-hotels/design-system/Typography"
import { useBookingFlowConfig } from "../../../../../../bookingFlowConfig/bookingFlowConfigContext"
import { getErrorMessage } from "../../../../../BookingFlowInput/errors"
import { RemoveExtraRooms } from "../../RemoveExtraRooms/RemoveExtraRooms"
import { isMultiRoomError } from "../../utils"
import styles from "./booking-code-error.module.css"
export function BookingCodeError({
codeError,
isDesktop = false,
}: {
codeError: FieldError
isDesktop?: boolean
}) {
const intl = useIntl()
const isMultiroomError = isMultiRoomError(codeError.message)
const config = useBookingFlowConfig()
return (
<div className={styles.errorContainer}>
<Typography
className={styles.error}
variant="Body/Supporting text (caption)/smRegular"
>
<span>
<MaterialIcon
size={20}
icon="error"
color="Icon/Feedback/Error"
isFilled={!isDesktop}
/>
{getErrorMessage(intl, config.variant, codeError.message)}
</span>
</Typography>
{isMultiroomError ? (
<div className={styles.removeButton}>
<RemoveExtraRooms />
</div>
) : null}
</div>
)
}

View File

@@ -0,0 +1,11 @@
.bookingCodeTooltip {
max-width: 560px;
margin-top: var(--Space-x2);
color: var(--Text-Secondary);
}
.infoButton {
align-self: center;
color: var(
--Icon-Interactive-Placeholder
) !important; /* Override IconButton default color */
}

View File

@@ -0,0 +1,50 @@
import { useIntl } from "react-intl"
import { IconButton } from "@scandic-hotels/design-system/IconButton"
import Modal from "@scandic-hotels/design-system/Modal"
import { Typography } from "@scandic-hotels/design-system/Typography"
import styles from "./code-rules-modal.module.css"
export default function CodeRulesModal() {
const intl = useIntl()
const codeVoucher = intl.formatMessage({
id: "booking.codeVoucher",
defaultMessage: "Code / Voucher",
})
const bookingCodeTooltipText = intl.formatMessage({
id: "bookingWidget.bookingCode.tooltip",
defaultMessage:
"If you're booking a promotional offer or a Corporate negotiated rate you'll need a special booking code. Don't use any special characters such as (.) (,) (-) (:). If you would like to make a booking with code VOF, please call us +46 8 517 517 20.Save your booking code for the next time you visit the page by ticking the box “Remember”. Don't tick the box if you're using a public computer to avoid unauthorized access to your booking code.",
})
const infoButtonAriaLabel = intl.formatMessage(
{
id: "bookingWidget.bookingCode.readMore",
defaultMessage: "Read more about using {codeVoucher}",
},
{
codeVoucher,
}
)
return (
<Modal
trigger={
<IconButton
variant="Muted"
emphasis
size="sm"
aria-label={infoButtonAriaLabel}
iconName="info"
className={styles.infoButton}
/>
}
title={codeVoucher}
>
<Typography variant="Body/Paragraph/mdRegular">
<p className={styles.bookingCodeTooltip}>{bookingCodeTooltipText}</p>
</Typography>
</Modal>
)
}

View File

@@ -0,0 +1,49 @@
import { useIntl } from "react-intl"
import { Button } from "@scandic-hotels/design-system/Button"
import Checkbox from "@scandic-hotels/design-system/Form/Checkbox"
import { Typography } from "@scandic-hotels/design-system/Typography"
import styles from "./remember-code.module.css"
type CodeRememberProps = {
bookingCodeValue: string | undefined
onApplyClick: () => void
}
export function RememberCode({
bookingCodeValue,
onApplyClick,
}: CodeRememberProps) {
const intl = useIntl()
const checkBoxLabel = intl.formatMessage({
id: "bookingWidget.bookingCode.remember",
defaultMessage: "Remember code",
})
return (
<>
<Checkbox name="bookingCode.remember" disabled={!bookingCodeValue}>
<Typography variant="Body/Supporting text (caption)/smRegular">
<span>{checkBoxLabel}</span>
</Typography>
</Checkbox>
<Button
size="sm"
className={styles.applyButton}
variant="Secondary"
wrapping
color="Primary"
type="button"
isDisabled={!bookingCodeValue}
onPress={onApplyClick}
>
{intl.formatMessage({
id: "common.apply",
defaultMessage: "Apply",
})}
</Button>
</>
)
}

View File

@@ -0,0 +1,9 @@
.applyButton {
min-width: 100px;
}
@media screen and (max-width: 767px) {
.applyButton {
display: none;
}
}

View File

@@ -10,6 +10,15 @@
background-color: var(--Background-Primary); background-color: var(--Background-Primary);
border-radius: var(--Corner-radius-md); border-radius: var(--Corner-radius-md);
padding: var(--Space-x1) var(--Space-x15); padding: var(--Space-x1) var(--Space-x15);
border: 2px solid transparent;
}
.bookingCode:focus-within,
.bookingCode:has([data-focused="true"]),
.bookingCode:has([data-pressed="true"]) {
background-color: var(--Surface-Primary-Hover);
border-radius: var(--Corner-radius-md);
border-color: var(--Border-Interactive-Focus);
} }
.bookingCodeLabel { .bookingCodeLabel {
@@ -20,21 +29,9 @@
color: var(--Text-Secondary); color: var(--Text-Secondary);
} }
.colorSecondary { .input {
color: var(--Text-Secondary); background-color: var(--Surface-Primary-Hover);
} color: var(--Text-Interactive-Focus);
.errorContainer {
display: flex;
flex-direction: column;
gap: var(--Space-x2);
}
.error {
display: flex;
gap: var(--Space-x1);
white-space: break-spaces;
color: var(--UI-Text-Error);
} }
.bookingCodeRemember, .bookingCodeRemember,
@@ -48,29 +45,10 @@
width: 100%; width: 100%;
} }
.bookingCodeTooltip {
max-width: 560px;
margin-top: var(--Space-x2);
color: var(--Text-Secondary);
}
.bookingCodeRememberVisible label { .bookingCodeRememberVisible label {
align-items: center; align-items: center;
} }
.removeButton {
width: 100%;
}
@media screen and (max-width: 767px) {
.hideOnMobile {
display: none;
}
.removeButton {
display: none;
}
}
@media screen and (min-width: 768px) { @media screen and (min-width: 768px) {
.bookingCode { .bookingCode {
height: auto; height: auto;
@@ -82,9 +60,6 @@
justify-content: space-between; justify-content: space-between;
border-radius: var(--Space-x15); border-radius: var(--Space-x15);
} }
.error {
color: var(--Text-Default);
}
} }
@media screen and (min-width: 768px) and (max-width: 1366px) { @media screen and (min-width: 768px) and (max-width: 1366px) {
@@ -113,24 +88,25 @@
} }
} }
@media screen and (max-width: 767px) {
.bookingCode {
display: flex;
flex-direction: column;
justify-content: center;
}
}
@media screen and (min-width: 1367px) { @media screen and (min-width: 1367px) {
.container:hover { .container:hover {
background-color: var(--Surface-Primary-Hover); background-color: var(--Surface-Primary-Hover);
border-radius: var(--Corner-radius-md); border-radius: var(--Corner-radius-md);
} }
.container:focus-within,
.container:has([data-focused="true"]),
.container:has([data-pressed="true"]) {
background-color: var(--Surface-Primary-Hover);
border-radius: var(--Corner-radius-md);
border: 2px solid var(--Border-Interactive-Focus);
}
.bookingCodeRememberVisible { .bookingCodeRememberVisible {
padding: var(--Space-x2); padding: var(--Space-x2);
position: absolute; position: absolute;
top: calc(100% + var(--Space-x3)); top: calc(100% + var(--Space-x3));
left: calc(0% - var(--Space-x05)); left: calc(0% - var(--Space-x05));
width: 360px; width: 320px;
box-shadow: var(--popup-box-shadow); box-shadow: var(--popup-box-shadow);
} }
} }

View File

@@ -1,24 +1,20 @@
import { useCallback, useEffect, useRef, useState } from "react" import { useCallback, useEffect, useRef, useState } from "react"
import { Dialog, DialogTrigger, Popover } from "react-aria-components" import { Dialog, DialogTrigger, Popover } from "react-aria-components"
import { type FieldError, useFormContext } from "react-hook-form" import { useFormContext } from "react-hook-form"
import { useIntl } from "react-intl" import { useIntl } from "react-intl"
import { useMediaQuery } from "usehooks-ts" import { useMediaQuery } from "usehooks-ts"
import { Button } from "@scandic-hotels/design-system/Button" import { Button } from "@scandic-hotels/design-system/Button"
import Checkbox from "@scandic-hotels/design-system/Form/Checkbox" import Checkbox from "@scandic-hotels/design-system/Form/Checkbox"
import { IconButton } from "@scandic-hotels/design-system/IconButton"
import { MaterialIcon } from "@scandic-hotels/design-system/Icons/MaterialIcon"
import Modal from "@scandic-hotels/design-system/Modal"
import Switch from "@scandic-hotels/design-system/Switch" import Switch from "@scandic-hotels/design-system/Switch"
import { Typography } from "@scandic-hotels/design-system/Typography" import { Typography } from "@scandic-hotels/design-system/Typography"
import { SEARCH_TYPE_REDEMPTION } from "@scandic-hotels/trpc/constants/booking" import { SEARCH_TYPE_REDEMPTION } from "@scandic-hotels/trpc/constants/booking"
import { useBookingFlowConfig } from "../../../../../bookingFlowConfig/bookingFlowConfigContext"
import BookingFlowInput from "../../../../BookingFlowInput" import BookingFlowInput from "../../../../BookingFlowInput"
import { getErrorMessage } from "../../../../BookingFlowInput/errors"
import { Input as BookingWidgetInput } from "../Input" import { Input as BookingWidgetInput } from "../Input"
import { RemoveExtraRooms } from "../RemoveExtraRooms/RemoveExtraRooms" import { BookingCodeError } from "./BookingCodeError"
import { isMultiRoomError } from "../utils" import CodeRulesModal from "./CodeRulesModal"
import { RememberCode } from "./RememberCode"
import styles from "./booking-code.module.css" import styles from "./booking-code.module.css"
@@ -106,6 +102,21 @@ export default function BookingCode() {
setShowRemember(true) setShowRemember(true)
} }
// Only show the Remember Code Popover if there is any text in the Booking Code text field
function hideRememberCheck() {
setShowRemember(false)
}
function resetRememberCheck() {
setValue("bookingCode.remember", false, { shouldDirty: true })
setValue("bookingCode.value", "", { shouldDirty: true })
setValue("bookingCode.flag", false, { shouldDirty: true })
}
useEffect(() => {
if (bookingCode?.value === "") {
setValue("bookingCode.remember", false, { shouldDirty: true })
}
}, [bookingCode?.value, setValue])
useEffect(() => { useEffect(() => {
setIsTablet(checkIsTablet) setIsTablet(checkIsTablet)
}, [checkIsTablet]) }, [checkIsTablet])
@@ -142,27 +153,48 @@ export default function BookingCode() {
<div <div
className={styles.container} className={styles.container}
ref={ref} ref={ref}
onFocus={showRememberCheck}
onBlur={(e) => closeIfOutside(e.nativeEvent.relatedTarget as HTMLElement)} onBlur={(e) => closeIfOutside(e.nativeEvent.relatedTarget as HTMLElement)}
> >
<div className={styles.bookingCode}> <div className={styles.bookingCode}>
<div className={styles.bookingCodeLabel}> <span className={styles.bookingCodeLabel}>
<Typography variant="Body/Supporting text (caption)/smBold"> <Typography variant="Body/Supporting text (caption)/smBold">
<span>{codeVoucher}</span> <label htmlFor="booking-code" id="bookingCodeLabel">
{codeVoucher}
</label>
</Typography> </Typography>
<CodeRulesModal /> <CodeRulesModal />
</div> </span>
<span className={styles.inputWrapper}>
<BookingWidgetInput <BookingWidgetInput
className="input" className={styles.input}
type="search" type="text"
placeholder={addCode} placeholder={addCode}
aria-labelledby="bookingCodeLabel"
name="bookingCode.value" name="bookingCode.value"
id="booking-code" id="booking-code"
onChange={(event) => updateBookingCodeFormValue(event.target.value)} onChange={(event) => {
updateBookingCodeFormValue(event.target.value)
if (!!bookingCode?.value) {
showRememberCheck()
} else {
hideRememberCheck()
resetRememberCheck()
}
}}
autoComplete="off" autoComplete="off"
value={bookingCode?.value} value={bookingCode?.value}
onFocus={() => {
if (!!bookingCode?.value) {
showRememberCheck()
}
}}
onBlur={(e) =>
closeIfOutside(e.nativeEvent.relatedTarget as HTMLElement)
}
/> />
</span>
</div> </div>
{isDesktop ? ( {isDesktop ? (
<div <div
className={ className={
@@ -174,7 +206,7 @@ export default function BookingCode() {
{codeError?.message ? ( {codeError?.message ? (
<BookingCodeError codeError={codeError} isDesktop /> <BookingCodeError codeError={codeError} isDesktop />
) : ( ) : (
<CodeRemember <RememberCode
bookingCodeValue={bookingCode?.value} bookingCodeValue={bookingCode?.value}
onApplyClick={() => setShowRemember(false)} onApplyClick={() => setShowRemember(false)}
/> />
@@ -210,106 +242,6 @@ export default function BookingCode() {
) )
} }
type CodeRememberProps = {
bookingCodeValue: string | undefined
onApplyClick: () => void
}
function CodeRulesModal() {
const intl = useIntl()
const codeVoucher = intl.formatMessage({
id: "booking.codeVoucher",
defaultMessage: "Code / Voucher",
})
const bookingCodeTooltipText = intl.formatMessage({
id: "bookingWidget.bookingCode.tooltip",
defaultMessage:
"If you're booking a promotional offer or a Corporate negotiated rate you'll need a special booking code. Don't use any special characters such as (.) (,) (-) (:). If you would like to make a booking with code VOF, please call us +46 8 517 517 20.Save your booking code for the next time you visit the page by ticking the box “Remember”. Don't tick the box if you're using a public computer to avoid unauthorized access to your booking code.",
})
return (
<Modal
trigger={
<IconButton variant="Muted" size="sm" emphasis iconName="info" />
}
title={codeVoucher}
>
<Typography variant="Body/Paragraph/mdRegular">
<p className={styles.bookingCodeTooltip}>{bookingCodeTooltipText}</p>
</Typography>
</Modal>
)
}
function CodeRemember({ bookingCodeValue, onApplyClick }: CodeRememberProps) {
const intl = useIntl()
return (
<>
<Checkbox name="bookingCode.remember">
<Typography variant="Body/Supporting text (caption)/smRegular">
<span>
{intl.formatMessage({
id: "bookingWidget.bookingCode.remember",
defaultMessage: "Remember code",
})}
</span>
</Typography>
</Checkbox>
{bookingCodeValue ? (
<Button
size="sm"
className={styles.hideOnMobile}
variant="Tertiary"
type="button"
onClick={onApplyClick}
>
{intl.formatMessage({
id: "common.apply",
defaultMessage: "Apply",
})}
</Button>
) : null}
</>
)
}
function BookingCodeError({
codeError,
isDesktop = false,
}: {
codeError: FieldError
isDesktop?: boolean
}) {
const intl = useIntl()
const isMultiroomError = isMultiRoomError(codeError.message)
const config = useBookingFlowConfig()
return (
<div className={styles.errorContainer}>
<Typography
className={styles.error}
variant="Body/Supporting text (caption)/smRegular"
>
<span>
<MaterialIcon
size={20}
icon="error"
color="Icon/Feedback/Error"
isFilled={!isDesktop}
/>
{getErrorMessage(intl, config.variant, codeError.message)}
</span>
</Typography>
{isMultiroomError ? (
<div className={styles.removeButton}>
<RemoveExtraRooms />
</div>
) : null}
</div>
)
}
function TabletBookingCode({ function TabletBookingCode({
bookingCode, bookingCode,
updateValue, updateValue,
@@ -340,6 +272,7 @@ function TabletBookingCode({
document.body.style.overflow = "clip !important" document.body.style.overflow = "clip !important"
} }
} }
if (!isOpen && !bookingCode?.value) { if (!isOpen && !bookingCode?.value) {
setValue("bookingCode.flag", false, { shouldDirty: true }) setValue("bookingCode.flag", false, { shouldDirty: true })
setIsOpen(isOpen) setIsOpen(isOpen)
@@ -367,8 +300,8 @@ function TabletBookingCode({
}, },
})} })}
> >
<Typography variant="Body/Supporting text (caption)/smBold"> <Typography variant="Body/Supporting text (caption)/smRegular">
<span className={styles.colorSecondary}>{codeVoucher}</span> <span>{codeVoucher}</span>
</Typography> </Typography>
</Checkbox> </Checkbox>
</Button> </Button>
@@ -395,7 +328,7 @@ function TabletBookingCode({
{codeError?.message ? ( {codeError?.message ? (
<BookingCodeError codeError={codeError} /> <BookingCodeError codeError={codeError} />
) : ( ) : (
<CodeRemember <RememberCode
bookingCodeValue={bookingCode?.value} bookingCodeValue={bookingCode?.value}
onApplyClick={close} onApplyClick={close}
/> />

View File

@@ -36,6 +36,14 @@ export default function RewardNight() {
const reward = getRewardMessage(config, intl) const reward = getRewardMessage(config, intl)
const rewardNightTooltip = getRewardNightTooltipMessage(config, intl) const rewardNightTooltip = getRewardNightTooltipMessage(config, intl)
const rewardLabel = intl.formatMessage(
{
id: "bookingWidget.reward.readMore",
defaultMessage: "Read more about booking with {reward}",
},
{ reward: reward }
)
const redemptionErr = errors[SEARCH_TYPE_REDEMPTION] const redemptionErr = errors[SEARCH_TYPE_REDEMPTION]
const isDesktop = useMediaQuery("(min-width: 767px)") const isDesktop = useMediaQuery("(min-width: 767px)")
@@ -45,6 +53,7 @@ export default function RewardNight() {
if (value && getValues("bookingCode.value")) { if (value && getValues("bookingCode.value")) {
setValue("bookingCode.flag", false) setValue("bookingCode.flag", false)
setValue("bookingCode.value", "", { shouldValidate: true }) setValue("bookingCode.value", "", { shouldValidate: true })
setValue("bookingCode.remember", false)
// Hide the notification popup after 5 seconds by re-triggering validation // Hide the notification popup after 5 seconds by re-triggering validation
// This is kept consistent with location search field error notification timeout // This is kept consistent with location search field error notification timeout
setTimeout(() => { setTimeout(() => {
@@ -83,6 +92,7 @@ export default function RewardNight() {
return ( return (
<div ref={ref} onBlur={(e) => closeOnBlur(e.nativeEvent)}> <div ref={ref} onBlur={(e) => closeOnBlur(e.nativeEvent)}>
<div className={styles.rewardNightLabel}>
<Checkbox <Checkbox
hideError hideError
name={SEARCH_TYPE_REDEMPTION} name={SEARCH_TYPE_REDEMPTION}
@@ -92,16 +102,23 @@ export default function RewardNight() {
}, },
}} }}
> >
<div className={styles.rewardNightLabel}>
<Typography <Typography
variant="Body/Supporting text (caption)/smRegular" variant="Body/Supporting text (caption)/smRegular"
className={styles.label} className={styles.label}
> >
<span>{reward}</span> <span>{reward}</span>
</Typography> </Typography>
</Checkbox>
<Modal <Modal
trigger={ trigger={
<IconButton variant="Muted" emphasis size="sm" iconName="info" /> <IconButton
className={styles.infoButton}
variant="Muted"
emphasis
size="sm"
iconName="info"
aria-label={rewardLabel}
/>
} }
title={reward} title={reward}
> >
@@ -113,7 +130,7 @@ export default function RewardNight() {
</Typography> </Typography>
</Modal> </Modal>
</div> </div>
</Checkbox>
{redemptionErr && ( {redemptionErr && (
<div className={styles.errorContainer}> <div className={styles.errorContainer}>
<Typography <Typography

View File

@@ -26,6 +26,12 @@
max-width: 560px; max-width: 560px;
} }
.infoButton {
align-self: center;
color: var(
--Icon-Interactive-Placeholder
) !important; /* Override IconButton default color */
}
@media screen and (max-width: 767px) { @media screen and (max-width: 767px) {
.hideOnMobile { .hideOnMobile {
display: none; display: none;

View File

@@ -310,7 +310,10 @@ function SearchListError({
<MaterialIcon icon="error" color="Icon/Interactive/Accent" /> <MaterialIcon icon="error" color="Icon/Interactive/Accent" />
{caption} {caption}
</Caption> </Caption>
<Typography variant="Body/Paragraph/mdRegular"> <Typography
className={styles.errorBody}
variant="Body/Paragraph/mdRegular"
>
<p>{body}</p> <p>{body}</p>
</Typography> </Typography>
</Dialog> </Dialog>

View File

@@ -36,3 +36,7 @@
.textPlaceholderColor { .textPlaceholderColor {
color: var(--UI-Text-Placeholder); color: var(--UI-Text-Placeholder);
} }
.errorBody {
color: var(--UI-Text-High-contrast);
}

View File

@@ -19,10 +19,6 @@
&:has(input:active, input:focus, input:focus-within) { &:has(input:active, input:focus, input:focus-within) {
background-color: var(--Surface-Primary-Hover); background-color: var(--Surface-Primary-Hover);
} }
&:has(input:active, input:focus, input:focus-within) {
border: 2px solid var(--Border-Interactive-Focus);
}
} }
.label { .label {

View File

@@ -1,7 +1,7 @@
import { useIntl } from "react-intl" import { useIntl } from "react-intl"
import Caption from "@scandic-hotels/design-system/Caption"
import { MaterialIcon } from "@scandic-hotels/design-system/Icons/MaterialIcon" import { MaterialIcon } from "@scandic-hotels/design-system/Icons/MaterialIcon"
import { Typography } from "@scandic-hotels/design-system/Typography"
import styles from "./validationError.module.css" import styles from "./validationError.module.css"
@@ -10,20 +10,34 @@ export default function ValidationError() {
return ( return (
<div className={styles.container}> <div className={styles.container}>
<Caption className={styles.title} color="red" type="bold"> <Typography
<MaterialIcon icon="dangerous" color="Icon/Feedback/Error" size={20} /> className={styles.title}
variant="Body/Supporting text (caption)/smBold"
>
<span>
<MaterialIcon
icon="error_circle_rounded"
color="Icon/Feedback/Error"
size={20}
/>
{intl.formatMessage({ {intl.formatMessage({
id: "bookingWidget.validationError.destination", id: "bookingWidget.validationError.destination",
defaultMessage: "Enter destination or hotel", defaultMessage: "Enter destination or hotel",
})} })}
</Caption> </span>
<Caption className={styles.message} type="regular"> </Typography>
<Typography
className={styles.message}
variant="Body/Supporting text (caption)/smRegular"
>
<span>
{intl.formatMessage({ {intl.formatMessage({
id: "bookingWidget.validationError.destinationDesc", id: "bookingWidget.validationError.destinationDesc",
defaultMessage: defaultMessage:
"A destination or hotel name is needed to be able to search for a hotel room.", "A destination or hotel name is needed to be able to search for a hotel room.",
})} })}
</Caption> </span>
</Typography>
</div> </div>
) )
} }

View File

@@ -20,9 +20,11 @@
display: flex; display: flex;
align-items: center; align-items: center;
gap: var(--Space-x1); gap: var(--Space-x1);
color: var(--UI-Text-Error);
} }
.message { .message {
color: var(--UI-Text-High-contrast);
text-wrap: auto; text-wrap: auto;
} }

View File

@@ -12,11 +12,14 @@
.rooms, .rooms,
.when { .when {
position: relative; position: relative;
border: 2px solid transparent;
} }
.buttonContainer { .buttonContainer {
align-self: center;
display: grid; display: grid;
gap: var(--Space-x1); gap: var(--Space-x1);
border: 2px solid transparent;
} }
.showOnTablet { .showOnTablet {
@@ -30,15 +33,41 @@
.label { .label {
color: var(--Text-Accent-Primary); color: var(--Text-Accent-Primary);
} }
.when:has([data-datepicker-open="true"]) .label, .when:has([data-datepicker-open="true"], [data-pressed="true"]) .label,
.rooms:has([data-pressed="true"]) .label { .rooms:has([data-rooms-open="true"], [data-pressed="true"]) .label {
color: var(--Text-Interactive-Focus); color: var(--Text-Interactive-Focus);
} }
.when:hover,
.rooms:hover {
background-color: var(--Surface-Primary-Hover);
}
.where:has(
[data-focus-visible="true"],
[data-focused="true"],
[data-pressed="true"]
),
.when:has(
[data-datepicker-open="true"],
[data-focus-visible="true"],
[data-pressed="true"]
),
.rooms:has(
[data-focus-visible="true"],
[data-rooms-open="true"],
[data-pressed="true"]
) {
background-color: var(--Surface-Primary-Hover);
border-color: var(--Border-Interactive-Focus);
color: var(--Text-Interactive-Focus);
}
@media screen and (max-width: 767px) { @media screen and (max-width: 767px) {
.voucherContainer { .voucherContainer {
padding: var(--Space-x2) 0 var(--Space-x4); padding: var(--Space-x2) 0 var(--Space-x4);
} }
.buttonContainer {
width: 100%;
}
} }
@media screen and (max-width: 1366px) { @media screen and (max-width: 1366px) {
@@ -83,16 +112,16 @@
display: flex; display: flex;
flex: 2; flex: 2;
gap: var(--Space-x2); gap: var(--Space-x2);
margin-left: calc(-1 * var(--Space-x15));
} }
.voucherContainer { .voucherContainer {
flex: 1; border-radius: 0 0 var(--Corner-radius-md) var(--Corner-radius-md);
} }
.rooms, .rooms,
.when, .when,
.where { .where {
width: 100%; width: 100%;
border-radius: var(--Corner-radius-md);
} }
.inputContainer input[type="text"] { .inputContainer input[type="text"] {
@@ -103,18 +132,6 @@
.rooms, .rooms,
.when { .when {
padding: var(--Space-x1) var(--Space-x15); padding: var(--Space-x1) var(--Space-x15);
border-radius: var(--Corner-radius-md);
}
.when:hover,
.rooms:hover {
background-color: var(--Surface-Primary-Hover);
}
.when:has([data-datepicker-open="true"]),
.rooms:has([data-focus-visible="true"], [data-pressed="true"]) {
background-color: var(--Surface-Primary-Hover);
border: 2px solid var(--Border-Interactive-Focus);
color: var(--Text-Interactive-Focus);
} }
.where { .where {
@@ -137,13 +154,18 @@
.input { .input {
flex-wrap: wrap; flex-wrap: wrap;
} }
.inputRow {
display: flex;
flex-direction: row;
gap: var(--Space-x2);
width: 100%;
padding: var(--Space-x2);
}
.inputContainer { .inputContainer {
padding: var(--Space-x2) var(--Space-x2) var(--Space-x2)
var(--Layout-Tablet-Margin-Margin-min);
flex-basis: 80%; flex-basis: 80%;
} }
.buttonContainer { .buttonContainer {
padding-right: var(--Layout-Tablet-Margin-Margin-min);
margin: 0; margin: 0;
} }
.input .buttonContainer .button { .input .buttonContainer .button {
@@ -153,12 +175,11 @@
} }
.voucherRow { .voucherRow {
flex: 1;
display: flex; display: flex;
background: var(--Base-Surface-Primary-light-Hover); background-color: var(--Base-Surface-Primary-light-Hover);
border-bottom: 1px solid var(--Primary-Light-On-Surface-Divider-subtle); border-bottom: 1px solid var(--Primary-Light-On-Surface-Divider-subtle);
padding: var(--Space-x2) var(--Layout-Tablet-Margin-Margin-min); padding-left: var(--Space-x2);
border-radius: 0 0 var(--Corner-radius-lg) var(--Corner-radius-lg);
margin-left: calc(var(--Space-x15) * -1);
} }
.showOnTablet { .showOnTablet {
@@ -173,6 +194,9 @@
.input { .input {
gap: var(--Space-x2); gap: var(--Space-x2);
} }
.inputRow {
flex: 1;
}
.bookingCodeDisabled { .bookingCodeDisabled {
flex: none; flex: none;

View File

@@ -62,6 +62,7 @@ export default function FormContent({
return ( return (
<div className={styles.input}> <div className={styles.input}>
<div className={styles.inputRow}>
<div className={styles.inputContainer}> <div className={styles.inputContainer}>
<div className={styles.where}> <div className={styles.where}>
<Search <Search
@@ -78,7 +79,7 @@ export default function FormContent({
variant="Body/Supporting text (caption)/smBold" variant="Body/Supporting text (caption)/smBold"
className={styles.label} className={styles.label}
> >
<label htmlFor="date"> <label id="bookingWidgetDatePickerLabel">
{nights > 0 {nights > 0
? intl.formatMessage( ? intl.formatMessage(
{ {
@@ -94,7 +95,7 @@ export default function FormContent({
})} })}
</label> </label>
</Typography> </Typography>
<DatePicker name="date" /> <DatePicker ariaLabelledBy="bookingWidgetDatePickerLabel" />
</div> </div>
<div className={styles.rooms}> <div className={styles.rooms}>
<Typography <Typography
@@ -121,6 +122,7 @@ export default function FormContent({
iconName="search" iconName="search"
/> />
</div> </div>
</div>
<div <div
className={cx( className={cx(
styles.voucherContainer, styles.voucherContainer,

View File

@@ -28,8 +28,7 @@
@media screen and (min-width: 1367px) { @media screen and (min-width: 1367px) {
.default { .default {
padding: var(--Space-x15) var(--Space-x2) var(--Space-x15) padding: var(--Space-x15) var(--Space-x2) var(--Space-x15) var(--Space-x1);
var(--Space-x1);
} }
.full { .full {
@@ -43,8 +42,7 @@
} }
.compact { .compact {
padding: var(--Space-x15) var(--Space-x2) var(--Space-x15) padding: var(--Space-x15) var(--Space-x2) var(--Space-x15) var(--Space-x15);
var(--Space-x1);
white-space: nowrap; white-space: nowrap;
} }
} }

View File

@@ -6,10 +6,9 @@ import { useIntl } from "react-intl"
import { Lang } from "@scandic-hotels/common/constants/language" import { Lang } from "@scandic-hotels/common/constants/language"
import { dt } from "@scandic-hotels/common/dt" import { dt } from "@scandic-hotels/common/dt"
import Caption from "@scandic-hotels/design-system/Caption" import { Button } from "@scandic-hotels/design-system/Button"
import { Divider } from "@scandic-hotels/design-system/Divider" import { Divider } from "@scandic-hotels/design-system/Divider"
import { MaterialIcon } from "@scandic-hotels/design-system/Icons/MaterialIcon" 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 { Typography } from "@scandic-hotels/design-system/Typography"
import useLang from "../../../../hooks/useLang" import useLang from "../../../../hooks/useLang"
@@ -112,20 +111,11 @@ export default function DatePickerRangeDesktop({
color="Border/Divider/Subtle" color="Border/Divider/Subtle"
/> />
<footer className={props.className}> <footer className={props.className}>
<Button <Button variant="Tertiary" onPress={close} size="sm">
intent="tertiary"
onPress={close}
size="small"
theme="base"
>
<Caption color="white" type="bold" asChild>
<span>
{intl.formatMessage({ {intl.formatMessage({
id: "datePicker.selectDates", id: "datePicker.selectDates",
defaultMessage: "Select dates", defaultMessage: "Select dates",
})} })}
</span>
</Caption>
</Button> </Button>
</footer> </footer>
</> </>

View File

@@ -5,8 +5,8 @@ import { useIntl } from "react-intl"
import { Lang } from "@scandic-hotels/common/constants/language" import { Lang } from "@scandic-hotels/common/constants/language"
import { dt } from "@scandic-hotels/common/dt" 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 { 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 { Typography } from "@scandic-hotels/design-system/Typography"
import useLang from "../../../../hooks/useLang" import useLang from "../../../../hooks/useLang"
@@ -131,19 +131,15 @@ export default function DatePickerRangeMobile({
<footer className={styles.footer}> <footer className={styles.footer}>
<Button <Button
className={styles.button} className={styles.button}
intent="tertiary" variant="Primary"
color="Primary"
size="md"
onPress={close} onPress={close}
size="large"
theme="base"
> >
<Typography variant="Body/Paragraph/mdBold">
<span>
{intl.formatMessage({ {intl.formatMessage({
id: "datePicker.selectDates", id: "datePicker.selectDates",
defaultMessage: "Select dates", defaultMessage: "Select dates",
})} })}
</span>
</Typography>
</Button> </Button>
</footer> </footer>
</div> </div>

View File

@@ -14,6 +14,7 @@ div.months {
.captionLabel { .captionLabel {
text-transform: capitalize; text-transform: capitalize;
color: var(--Text-Default);
} }
td.day, td.day,

View File

@@ -24,7 +24,7 @@
align-self: flex-end; align-self: flex-end;
background-color: var(--Main-Grey-White); background-color: var(--Main-Grey-White);
grid-area: header; grid-area: header;
padding: var(--Space-x3) var(--Space-x2); padding: 0 var(--Space-x2) 0;
position: sticky; position: sticky;
top: 0; top: 0;
z-index: 10; z-index: 10;

View File

@@ -1,6 +1,7 @@
.btn { .triggerButton {
background: none; background: none;
border: none; border: none;
color: var(--Text-Default);
cursor: pointer; cursor: pointer;
outline: none; outline: none;
padding: 0; padding: 0;
@@ -12,55 +13,63 @@
bottom: 0; bottom: 0;
right: 0; right: 0;
padding: 20px var(--Space-x15) 0; padding: 20px var(--Space-x15) 0;
border-radius: var(--Corner-radius-lg);
} }
.body { .datePicker[data-datepicker-open="true"] {
color: var(--Text-Default);
}
.hideWrapper {
background-color: var(--Main-Grey-White);
display: none;
}
.container[data-datepicker-open="true"] .hideWrapper {
display: block; 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) { @media screen and (max-width: 1366px) {
.container { .datePicker {
z-index: 10001;
height: 24px; height: 24px;
} }
.hideWrapper { .datePicker[data-datepicker-open="true"] {
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 {
border-radius: var(--Corner-radius-lg) var(--Corner-radius-lg) 0 0; border-radius: var(--Corner-radius-lg) var(--Corner-radius-lg) 0 0;
top: calc(max(var(--sitewide-alert-sticky-height), 20px)); top: calc(max(var(--sitewide-alert-sticky-height), 20px));
} }
} }
@media screen and (min-width: 1367px) { @media screen and (min-width: 1367px) {
.hideWrapper { .datePicker {
display: block;
}
.pickerContainer {
position: absolute;
display: grid;
border-radius: var(--Corner-radius-lg); border-radius: var(--Corner-radius-lg);
box-shadow: var(--popup-box-shadow); box-shadow: var(--popup-box-shadow);
padding: var(--Space-x2) var(--Space-x3); padding: var(--Space-x2) var(--Space-x3);
position: absolute; max-width: calc(100vw - 20px);
/** max-height: 440px;
BookingWidget padding + top: calc(100% + 36px);
border-width + left: auto;
wanted space below booking widget right: auto;
*/ bottom: auto;
top: calc(100% + var(--Space-x1) + 1px + var(--Space-x4)); overflow: visible;
}
.triggerButton {
display: block;
overflow: hidden;
text-overflow: ellipsis;
border-radius: var(--Corner-radius-md);
} }
} }

View File

@@ -1,10 +1,14 @@
"use client" "use client"
import { useCallback, useEffect, useRef, useState } from "react" 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 { useFormContext, useWatch } from "react-hook-form"
import { useIntl } from "react-intl" import { useIntl } from "react-intl"
import { useMediaQuery } from "usehooks-ts"
import { longDateFormat } from "@scandic-hotels/common/constants/dateFormats" import { longDateFormat } from "@scandic-hotels/common/constants/dateFormats"
import { dt } from "@scandic-hotels/common/dt" import { dt } from "@scandic-hotels/common/dt"
import { useScrollLock } from "@scandic-hotels/common/hooks/useScrollLock"
import { Typography } from "@scandic-hotels/design-system/Typography" import { Typography } from "@scandic-hotels/design-system/Typography"
import useLang from "../../../hooks/useLang" import useLang from "../../../hooks/useLang"
@@ -16,17 +20,24 @@ import styles from "./date-picker.module.css"
import type { DateRange } from "react-day-picker" import type { DateRange } from "react-day-picker"
type DatePickerFormProps = { type DatePickerFormProps = {
ariaLabelledBy?: string
name?: string name?: string
} }
export default function DatePickerForm({ name = "date" }: DatePickerFormProps) { export default function DatePickerForm({
ariaLabelledBy,
name = "date",
}: DatePickerFormProps) {
const lang = useLang() 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 [isOpen, setIsOpen] = useState(false)
const selectedDate = useWatch({ name }) const selectedDate = useWatch({ name })
const { register, setValue } = useFormContext() const { setValue } = useFormContext()
const ref = useRef<HTMLDivElement | null>(null) const ref = useRef<HTMLDivElement | null>(null)
const close = useCallback(() => { const close = useCallback(() => {
if (!selectedDate.toDate) { if (!selectedDate.toDate) {
setValue( setValue(
@@ -38,13 +49,21 @@ export default function DatePickerForm({ name = "date" }: DatePickerFormProps) {
{ shouldDirty: true } { shouldDirty: true }
) )
} }
setIsOpen(false) setIsOpen(false)
}, [name, setValue, selectedDate]) unlockScroll()
}, [name, setValue, selectedDate, unlockScroll])
function showOnFocus() { const { overlayProps, underlayProps } = useOverlay(
setIsOpen(true) {
} isOpen,
onClose: () => {
setIsOpen(false)
unlockScroll()
},
isDismissable: true,
},
ref
)
function handleSelectDate( function handleSelectDate(
_nextRange: DateRange | undefined, _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(() => { useEffect(() => {
function handleClickOutside(evt: Event) { setIsDesktop(checkIsDesktop)
if (isOpen) { }, [checkIsDesktop])
const target = evt.target as HTMLElement
closeIfOutside(target)
}
}
document.body.addEventListener("click", handleClickOutside)
return () => {
document.body.removeEventListener("click", handleClickOutside)
}
}, [closeIfOutside, isOpen])
const selectedFromDate = dt(selectedDate.fromDate) const selectedFromDate = dt(selectedDate.fromDate)
.locale(lang) .locale(lang)
@@ -134,39 +128,25 @@ export default function DatePickerForm({ name = "date" }: DatePickerFormProps) {
? dt(selectedDate.toDate).locale(lang).format(longDateFormat[lang]) ? dt(selectedDate.toDate).locale(lang).format(longDateFormat[lang])
: "" : ""
return ( return isDesktop ? (
<div <div className={styles.datePicker}>
className={styles.container} <Trigger
onBlur={(e) => { ariaLabelledBy={ariaLabelledBy}
closeOnBlur(e.nativeEvent) onPress={() => {
setIsOpen((prev) => !prev)
}} }}
data-datepicker-open={isOpen} selectedFromDate={selectedFromDate}
selectedToDate={selectedToDate}
/>
{isOpen && (
<div {...underlayProps}>
<FocusScope contain restoreFocus autoFocus>
<div
{...overlayProps}
ref={ref} ref={ref}
className={styles.pickerContainer}
data-datepicker-open={isOpen}
> >
<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 <DatePickerRangeDesktop
close={close} close={close}
handleOnSelect={handleSelectDate} handleOnSelect={handleSelectDate}
@@ -178,7 +158,35 @@ export default function DatePickerForm({ name = "date" }: DatePickerFormProps) {
: undefined, : 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 && ( {isOpen && (
<div {...underlayProps}>
<FocusScope contain restoreFocus autoFocus>
<div
{...overlayProps}
ref={ref}
className={styles.pickerContainer}
data-datepicker-open={isOpen}
>
<DatePickerRangeMobile <DatePickerRangeMobile
close={close} close={close}
handleOnSelect={handleSelectDate} handleOnSelect={handleSelectDate}
@@ -190,8 +198,52 @@ export default function DatePickerForm({ name = "date" }: DatePickerFormProps) {
: undefined, : undefined,
}} }}
/> />
)}
</div> </div>
</FocusScope>
</div>
)}
</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" />
</>
)
}

View File

@@ -1,11 +1,12 @@
"use client" "use client"
import { useEffect, useRef, useState } from "react"
import { useFormContext } from "react-hook-form" import { useFormContext } from "react-hook-form"
import { useIntl } from "react-intl" 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 { 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 { ChildBedMapEnum } from "@scandic-hotels/trpc/enums/childBedMapEnum"
import styles from "./child-selector.module.css" import styles from "./child-selector.module.css"
@@ -38,6 +39,34 @@ export default function ChildInfoSelector({
index = 0, index = 0,
roomIndex = 0, roomIndex = 0,
}: ChildInfoSelectorProps) { }: 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 ageFieldName = `rooms.${roomIndex}.childrenInRoom.${index}.age`
const bedFieldName = `rooms.${roomIndex}.childrenInRoom.${index}.bed` const bedFieldName = `rooms.${roomIndex}.childrenInRoom.${index}.bed`
const intl = useIntl() const intl = useIntl()
@@ -49,10 +78,12 @@ export default function ChildInfoSelector({
id: "booking.bedPreference", id: "booking.bedPreference",
defaultMessage: "Bed preference", defaultMessage: "Bed preference",
}) })
const errorMessage = intl.formatMessage({ const errorMessage = intl.formatMessage({
id: "bookingWidget.child.ageRequiredError", id: "bookingWidget.child.ageRequiredError",
defaultMessage: "Child age is required", defaultMessage: "Child age is required",
}) })
const { setValue, formState } = useFormContext() const { setValue, formState } = useFormContext()
function updateSelectedBed(bed: number) { function updateSelectedBed(bed: number) {
@@ -113,50 +144,62 @@ export default function ChildInfoSelector({
return ( return (
<> <>
<div key={index} className={styles.childInfoContainer}> <div key={index} className={styles.childInfoContainer}>
<div> <div ref={ageSelectRef}>
<DeprecatedSelect <Select
required={true} isRequired
items={ageList} items={ageList}
name={ageFieldName}
label={ageLabel} label={ageLabel}
aria-label={ageLabel} onSelectionChange={(key) => {
value={child.age ?? childDefaultValues.age}
onSelect={(key) => {
updateSelectedAge(key as number) updateSelectedAge(key as number)
}} }}
maxHeight={180} popoverWidth={`${ageWidth}px`}
name={ageFieldName} value={child.age ?? childDefaultValues.age}
isNestedInModal={true} isInvalid={!!ageError}
/> />
</div> </div>
<div> <div ref={bedPrefSelectRef}>
{child.age >= 0 ? ( {child.age >= 0 ? (
<DeprecatedSelect <Select
isRequired
items={getAvailableBeds(child.age)} items={getAvailableBeds(child.age)}
name={bedFieldName}
label={bedLabel} label={bedLabel}
aria-label={bedLabel} aria-label={bedLabel}
value={child.bed ?? childDefaultValues.bed} value={child.bed ?? childDefaultValues.bed}
onSelect={(key) => { onSelectionChange={(key) => {
updateSelectedBed(key as number) updateSelectedBed(key as number)
}} }}
name={bedFieldName} popoverWidth={`${bedWidth}px`}
isNestedInModal={true}
/> />
) : null} ) : null}
</div> </div>
</div> </div>
{roomErrors && roomErrors.message ? ( {roomErrors && roomErrors.message ? (
<Caption color="red" className={styles.error}> <Typography
variant="Body/Supporting text (caption)/smRegular"
className={styles.error}
>
<span>
<MaterialIcon icon="error" color="Icon/Interactive/Accent" /> <MaterialIcon icon="error" color="Icon/Interactive/Accent" />
{roomErrors.message} {roomErrors.message}
</Caption> </span>
</Typography>
) : null} ) : null}
{ageError || bedError ? ( {ageError || bedError ? (
<Caption color="red" className={styles.error}> <>
<MaterialIcon icon="error" color="Icon/Interactive/Accent" /> <Typography
variant="Body/Supporting text (caption)/smRegular"
className={styles.error}
>
<span>
<MaterialIcon icon="error" color="Icon/Feedback/Error" />
{errorMessage} {errorMessage}
</Caption> </span>
</Typography>
</>
) : null} ) : null}
</> </>
) )

View File

@@ -15,6 +15,7 @@
} }
.error { .error {
color: var(--Text-Interactive-Error);
display: flex; display: flex;
align-items: center; align-items: center;
gap: var(--Space-x1); gap: var(--Space-x1);

View File

@@ -6,11 +6,11 @@ import { useIntl } from "react-intl"
import { Button } from "@scandic-hotels/design-system/Button" import { Button } from "@scandic-hotels/design-system/Button"
import { MaterialIcon } from "@scandic-hotels/design-system/Icons/MaterialIcon" 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 { Typography } from "@scandic-hotels/design-system/Typography"
import { SEARCH_TYPE_REDEMPTION } from "@scandic-hotels/trpc/constants/booking" import { SEARCH_TYPE_REDEMPTION } from "@scandic-hotels/trpc/constants/booking"
import { useBookingFlowConfig } from "../../../bookingFlowConfig/bookingFlowConfigContext" import { useBookingFlowConfig } from "../../../bookingFlowConfig/bookingFlowConfigContext"
import { useIsDesktop } from "../../../hooks/useBreakpoint"
import { GuestsRoom } from "./GuestsRoom" import { GuestsRoom } from "./GuestsRoom"
import styles from "./guests-rooms-picker.module.css" import styles from "./guests-rooms-picker.module.css"
@@ -30,6 +30,7 @@ export default function GuestsRoomsPickerDialog({
onClose, onClose,
}: GuestsRoomsPickerDialogProps) { }: GuestsRoomsPickerDialogProps) {
const intl = useIntl() const intl = useIntl()
const isDesktop = useIsDesktop()
const config = useBookingFlowConfig() const config = useBookingFlowConfig()
const { getFieldState, trigger, setValue, getValues } = const { getFieldState, trigger, setValue, getValues } =
useFormContext<BookingWidgetSchema>() useFormContext<BookingWidgetSchema>()
@@ -61,6 +62,11 @@ export default function GuestsRoomsPickerDialog({
defaultMessage: defaultMessage:
"Multi-room booking is not available with this booking code.", "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 handleClose = useCallback(async () => {
const isValid = await trigger("rooms") const isValid = await trigger("rooms")
@@ -97,13 +103,11 @@ export default function GuestsRoomsPickerDialog({
if (fieldState.invalid) trigger("rooms") if (fieldState.invalid) trigger("rooms")
}, [roomsValue, getFieldState, trigger]) }, [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 canAddRooms = rooms.length < MAX_ROOMS
const addRoomButtonDisabled =
!!addRoomDisabledTextForSpecialRate || !canAddRooms
return ( return (
<> <>
<section className={styles.contentWrapper}> <section className={styles.contentWrapper}>
@@ -122,20 +126,24 @@ export default function GuestsRoomsPickerDialog({
onRemove={handleRemoveRoom} onRemove={handleRemoveRoom}
/> />
))} ))}
{!isDesktop && (
{addRoomDisabledTextForSpecialRate ? ( <>
<div className={styles.addRoomMobileContainer}> <div className={styles.addRoomBtnContainer}>
<Button <Button
className={styles.addRoomBtn}
variant="Text" variant="Text"
color="Primary"
wrapping wrapping
color="Primary"
onPress={handleAddRoom} onPress={handleAddRoom}
isDisabled isDisabled={addRoomButtonDisabled}
size="sm" size="sm"
> >
<MaterialIcon icon="add" color="CurrentColor" /> <MaterialIcon icon="add" color="CurrentColor" />
{addRoomLabel} {addRoomLabel}
</Button> </Button>
</div>
{addRoomDisabledTextForSpecialRate && (
<div className={styles.errorContainer}> <div className={styles.errorContainer}>
<Typography <Typography
className={styles.error} className={styles.error}
@@ -152,84 +160,55 @@ export default function GuestsRoomsPickerDialog({
</span> </span>
</Typography> </Typography>
</div> </div>
</div> )}
) : ( </>
canAddRooms && (
<div className={styles.addRoomMobileContainer}>
<Button
className={styles.addRoomBtn}
variant="Text"
wrapping
color="Primary"
onPress={handleAddRoom}
size="sm"
>
<MaterialIcon icon="add" color="CurrentColor" />
{addRoomLabel}
</Button>
</div>
)
)} )}
</div> </div>
</section> </section>
<footer className={styles.footer}> <footer className={styles.footer}>
{addRoomDisabledTextForSpecialRate ? ( <div className={styles.footerButtons}>
<div className={styles.hideOnMobile}> {isDesktop && (
<Tooltip
text={addRoomDisabledTextForSpecialRate}
position="bottom"
arrow="left"
>
<Button <Button
variant="Text" variant="Text"
wrapping wrapping
color="Primary" color="Primary"
isDisabled isDisabled={addRoomButtonDisabled}
size="sm" size="sm"
onPress={handleAddRoom} onPress={handleAddRoom}
> >
<MaterialIcon icon="add_circle" color="CurrentColor" /> <MaterialIcon icon="add_circle" color="CurrentColor" />
{addRoomLabel} {addRoomLabel}
</Button> </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>
</div>
)
)} )}
<Button <Button
onPress={handleClose} onPress={handleClose}
isDisabled={isInvalid} isDisabled={isInvalid}
className={styles.hideOnDesktop} className={styles.doneButton}
variant="Tertiary" variant={isDesktop ? "Tertiary" : "Primary"}
color="Primary" color="Primary"
size="sm" size={isDesktop ? "sm" : "md"}
> >
{doneLabel} {doneLabel}
</Button> </Button>
<Button </div>
onPress={handleClose}
isDisabled={isInvalid} {/* DESKTOP INLINE ERROR MESSAGE */}
className={styles.hideOnMobile} {addRoomDisabledTextForSpecialRate && isDesktop && (
variant="Tertiary" <Typography
color="Primary" className={styles.error}
size="sm" variant="Body/Supporting text (caption)/smRegular"
> >
{doneLabel} <div className={styles.errorContainer}>
</Button> <MaterialIcon
icon="error"
size={20}
color="Icon/Feedback/Error"
isFilled
/>
{addRoomDisabledTextForSpecialRate}
</div>
</Typography>
)}
</footer> </footer>
</> </>
) )

View File

@@ -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>
)
}

View File

@@ -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;
}
}

View File

@@ -1,30 +1,17 @@
.triggerDesktop {
display: none;
}
.errorContainer { .errorContainer {
display: flex; 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 { .error {
display: flex; display: flex;
gap: var(--Space-x1); gap: var(--Space-x1);
color: var(--UI-Text-Error); color: var(--Text-Feedback-Error);
} text-wrap: wrap;
align-items: center;
.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;
} }
.contentWrapper { .contentWrapper {
@@ -35,10 +22,6 @@
grid-template-rows: var(--header-height) calc(100dvh - var(--header-height)); grid-template-rows: var(--header-height) calc(100dvh - var(--header-height));
} }
.pickerContainerDesktop {
display: none;
}
.roomContainer { .roomContainer {
display: grid; display: grid;
gap: var(--Space-x2); gap: var(--Space-x2);
@@ -69,6 +52,7 @@
bottom: 0; bottom: 0;
right: 0; right: 0;
padding: 20px var(--Space-x15) 0; padding: 20px var(--Space-x15) 0;
border-radius: var(--Corner-radius-lg);
} }
.guestsAndRooms { .guestsAndRooms {
@@ -77,10 +61,18 @@
.footer { .footer {
display: flex; display: flex;
flex-direction: row; flex-direction: column;
gap: var(--Space-x1); gap: var(--Space-x2);
} }
.addRoomBtnContainer {
display: flex;
justify-content: center;
}
.footerButtons {
display: flex;
justify-content: space-between;
}
.roomContainer { .roomContainer {
padding: var(--Space-x2); padding: var(--Space-x2);
} }
@@ -97,15 +89,27 @@
width: 100%; width: 100%;
} }
.contentWrapper .contentWrapper .addRoomBtn:is(:focus, :focus-visible, :focus-within),
.addRoomMobileContainer .footer .addRoomBtn:is(:focus, :focus-visible, :focus-within),
.addRoomBtn:is(:focus, :focus-visible, :focus-within),
.footer .hideOnMobile .addRoomBtn:is(:focus, :focus-visible, :focus-within),
.roomActionsButton:is(:focus, :focus-visible, :focus-within) { .roomActionsButton:is(:focus, :focus-visible, :focus-within) {
outline: var(--Border-Interactive-Focus) auto 1px;
text-decoration: none; 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) { @media screen and (max-width: 1366px) {
.contentContainer { .contentContainer {
grid-area: content; grid-area: content;
@@ -140,43 +144,52 @@
width: 100%; width: 100%;
} }
.footer .hideOnMobile {
display: none;
}
.addRoomMobileContainer { .addRoomMobileContainer {
display: grid; display: grid;
padding-bottom: calc(var(--sticky-button-height) + 20px); padding-bottom: var(--Space-x3);
}
.errorContainer {
margin: var(--Space-x2);
} }
.addRoomMobileContainer button { .addRoomMobileContainer button {
width: 150px; width: 150px;
margin: 0 auto; 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) { @media screen and (min-width: 1367px) {
.container { .container {
height: 24px; height: 24px;
} }
.pickerContainerMobile {
display: none;
}
.contentWrapper { .contentWrapper {
grid-template-rows: auto; 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 { .roomContainer {
padding: var(--Space-x2) 0 0 0; padding: var(--Space-x2) 0 0 0;
} }
@@ -193,34 +206,13 @@
overflow-y: visible; overflow-y: visible;
} }
.triggerMobile { .trigger > span {
display: none;
}
.triggerDesktop {
display: block;
}
.triggerDesktop > span {
display: block; display: block;
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
} }
.pickerContainerDesktop { .pickerContainer:focus-visible {
--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 {
outline: none; outline: none;
} }
@@ -229,18 +221,13 @@
} }
.footer { .footer {
grid-template-columns: auto auto; display: flex;
justify-content: space-between;
padding-top: var(--Space-x2); padding-top: var(--Space-x2);
height: fit-content;
} }
.footer button { .footer button {
margin-left: auto;
width: auto; width: auto;
min-width: 125px;
}
.footer .hideOnDesktop,
.addRoomMobileContainer {
display: none;
} }
} }

View File

@@ -1,19 +1,16 @@
"use client" "use client"
import { useCallback, useEffect, useId, useState } from "react" import { useCallback, useEffect, useRef, useState } from "react"
import { import { FocusScope, useOverlay } from "react-aria"
Button, import { Button } from "react-aria-components"
Dialog,
DialogTrigger,
Modal,
Popover,
} from "react-aria-components"
import { useFormContext, useWatch } from "react-hook-form" import { useFormContext, useWatch } from "react-hook-form"
import { useIntl } from "react-intl" import { useIntl } from "react-intl"
import { useMediaQuery } from "usehooks-ts" import { useMediaQuery } from "usehooks-ts"
import { useScrollLock } from "@scandic-hotels/common/hooks/useScrollLock"
import { Typography } from "@scandic-hotels/design-system/Typography" import { Typography } from "@scandic-hotels/design-system/Typography"
import ValidationError from "./ValidationError/index"
import PickerForm from "./Form" import PickerForm from "./Form"
import styles from "./guests-rooms-picker.module.css" import styles from "./guests-rooms-picker.module.css"
@@ -26,107 +23,175 @@ export default function GuestsRoomsPickerForm({
}: { }: {
ariaLabelledBy?: string ariaLabelledBy?: string
}) { }) {
const { trigger } = useFormContext<BookingWidgetSchema>()
const rooms = useWatch<BookingWidgetSchema, "rooms">({ name: "rooms" }) const rooms = useWatch<BookingWidgetSchema, "rooms">({ name: "rooms" })
const popoverId = useId()
const checkIsDesktop = useMediaQuery("(min-width: 1367px)") const checkIsDesktop = useMediaQuery("(min-width: 1367px)")
const [isDesktop, setIsDesktop] = useState(true) const [isDesktop, setIsDesktop] = useState(true)
const [isOpen, setIsOpen] = useState(false) const [isOpen, setIsOpen] = useState(false)
const [containerHeight, setContainerHeight] = useState(0) const [containerConstraint, setContainerConstraint] = useState(0)
const childCount = rooms[0]?.childrenInRoom.length ?? 0 // ToDo Update for multiroom later const [showErrorModal, setShowErrorModal] = useState(false)
//isOpen is the 'old state', so isOpen === true means "The modal is open and WILL be closed". const ref = useRef<HTMLDivElement | null>(null)
async function setOverflowClip(isOpen: boolean) { // const childCount = rooms[0]?.childrenInRoom.length ?? 0 // ToDo Update for multiroom later
const bodyElement = document.body const {
if (bodyElement) { clearErrors,
if (isOpen) { formState: { errors },
bodyElement.style.overflow = "visible" } = useFormContext()
} else {
// !important needed to override 'overflow: hidden' set by react-aria. const [scrollPosition, setScrollPosition] = useState(0)
// 'overflow: hidden' does not work in combination with other sticky positioned elements, which clip does. const roomError = errors["rooms"]
bodyElement.style.overflow = "clip !important" const { lockScroll, unlockScroll } = useScrollLock({
} autoLock: false,
} })
if (!isOpen) { useEffect(() => {
const state = await trigger("rooms") if (roomError) {
if (state) { setShowErrorModal(true)
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(() => { useEffect(() => {
setIsDesktop(checkIsDesktop) setIsDesktop(checkIsDesktop)
}, [checkIsDesktop]) }, [checkIsDesktop])
const updateHeight = useCallback(() => { const updateHeight = useCallback((containerConstraint: number) => {
// Get available space for picker to show without going beyond screen // Get available space for picker to show without going beyond screen
const bookingWidget = document.getElementById("booking-widget") const bookingWidget = document.getElementById("booking-widget")
const popoverElement = document.getElementById("guestsPopover")
const maxHeight = const maxHeight =
window.innerHeight - window.innerHeight -
(bookingWidget?.getBoundingClientRect().bottom ?? 0) - (bookingWidget?.getBoundingClientRect().bottom ?? 0) -
50 50
const innerContainerHeight = document const innerContainerHeight = popoverElement?.getBoundingClientRect().height
.getElementsByClassName(popoverId)[0]
?.getBoundingClientRect().height const shouldAdjustHeight = Boolean(
if ( // height should be constrained
maxHeight != containerHeight && maxHeight != containerConstraint &&
innerContainerHeight && innerContainerHeight &&
maxHeight <= innerContainerHeight maxHeight <= innerContainerHeight
) { )
setContainerHeight(maxHeight) const hasExcessVerticalSpace = Boolean(
} else if ( // no need to constrain height
containerHeight && containerConstraint &&
innerContainerHeight && innerContainerHeight &&
maxHeight > innerContainerHeight Math.floor(maxHeight) > Math.floor(innerContainerHeight)
) { )
setContainerHeight(0) if (shouldAdjustHeight) {
// avoid clipping if there's only one room
setContainerConstraint(Math.max(200, maxHeight))
} else if (hasExcessVerticalSpace) {
setContainerConstraint(0)
} }
}, [containerHeight, popoverId]) }, [])
useEffect(() => { useEffect(() => {
if (isDesktop && rooms.length > 0) { if (isDesktop && rooms.length > 1) {
updateHeight() updateHeight(containerConstraint)
} }
}, [childCount, isDesktop, updateHeight, rooms]) }, [
isOpen,
scrollPosition,
isDesktop,
updateHeight,
containerConstraint,
rooms,
])
return isDesktop ? ( useEffect(() => {
<DialogTrigger onOpenChange={setOverflowClip} isOpen={isOpen}> 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 <Trigger
rooms={rooms} rooms={rooms}
className={styles.triggerDesktop} className={styles.trigger}
triggerFn={() => { triggerFn={() => {
setIsOpen(true) setIsOpen((prev) => !prev)
}} if (!isDesktop && !isOpen) {
/> lockScroll()
<Popover } else {
className={popoverId} unlockScroll()
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} ariaLabelledBy={ariaLabelledBy}
/> />
<Modal>
<Dialog className={styles.pickerContainerMobile}> {isOpen && (
{({ close }) => <PickerForm rooms={rooms} onClose={close} />} <div {...underlayProps}>
</Dialog> <FocusScope contain restoreFocus autoFocus>
</Modal> <div
</DialogTrigger> 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 />}
</>
) )
} }

View File

@@ -24,7 +24,8 @@
} }
/* Make sure Date Picker is placed on top of other sticky/fixed components */ /* 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); z-index: var(--booking-widget-open-z-index);
} }
} }
@@ -37,7 +38,7 @@
gap: var(--Space-x3); gap: var(--Space-x3);
height: calc(100dvh - max(var(--sitewide-alert-sticky-height), 20px)); height: calc(100dvh - max(var(--sitewide-alert-sticky-height), 20px));
width: 100%; width: 100%;
padding: var(--Space-x3) var(--Space-x2) var(--Space-x7); padding: var(--Space-x3) var(--Space-x2);
position: fixed; position: fixed;
left: 0; left: 0;
bottom: -100%; bottom: -100%;
@@ -85,7 +86,6 @@
&.compactFormContainer { &.compactFormContainer {
box-shadow: none; box-shadow: none;
padding-left: var(--Space-x15);
} }
} }

View File

@@ -64,6 +64,7 @@
"libphonenumber-js": "^1.12.15", "libphonenumber-js": "^1.12.15",
"motion": "^12.10.0", "motion": "^12.10.0",
"nuqs": "2.4.3", "nuqs": "2.4.3",
"react-aria": "^3.39.0",
"react-aria-components": "1.8.0", "react-aria-components": "1.8.0",
"react-day-picker": "^9.6.7", "react-day-picker": "^9.6.7",
"react-hook-form": "^7.56.2", "react-hook-form": "^7.56.2",

View File

@@ -14,7 +14,7 @@
} }
&[data-disabled] { &[data-disabled] {
cursor: unset; cursor: not-allowed;
} }
&[data-pending] { &[data-pending] {

View File

@@ -32,6 +32,7 @@ const CheckboxComponent = forwardRef<
hideError, hideError,
topAlign = false, topAlign = false,
errorCodeMessages, errorCodeMessages,
disabled = false,
}, },
ref ref
) { ) {
@@ -49,7 +50,7 @@ const CheckboxComponent = forwardRef<
onChange={field.onChange} onChange={field.onChange}
data-testid={name} data-testid={name}
name={name} name={name}
isDisabled={registerOptions?.disabled} isDisabled={registerOptions?.disabled || disabled}
excludeFromTabOrder excludeFromTabOrder
> >
{({ isSelected }) => ( {({ isSelected }) => (

View File

@@ -25,6 +25,7 @@ export function Select({
isDisabled, isDisabled,
icon, icon,
itemIcon, itemIcon,
popoverWidth,
...props ...props
}: SelectProps | SelectFilterProps) { }: SelectProps | SelectFilterProps) {
const [isOpen, setIsOpen] = useState(false) const [isOpen, setIsOpen] = useState(false)
@@ -88,7 +89,11 @@ export function Select({
/> />
</Button> </Button>
<Popover className={styles.popover} shouldFlip={false}> <Popover
className={styles.popover}
style={popoverWidth ? { minWidth: popoverWidth } : undefined}
shouldFlip={false}
>
<ListBox className={styles.listBox}> <ListBox className={styles.listBox}>
{items.map((item, idx) => ( {items.map((item, idx) => (
<SelectItem <SelectItem

View File

@@ -18,6 +18,7 @@ export interface SelectProps extends ComponentProps<typeof Select> {
label: string label: string
onSelectionChange?: (key: Key | null) => void onSelectionChange?: (key: Key | null) => void
enableFiltering?: false enableFiltering?: false
popoverWidth?: string
} }
export interface SelectItemProps extends ComponentProps<typeof ListBoxItem> { export interface SelectItemProps extends ComponentProps<typeof ListBoxItem> {
@@ -35,4 +36,5 @@ export interface SelectFilterProps extends ComponentProps<typeof ComboBox> {
label: string label: string
onSelectionChange?: (key: Key | null) => void onSelectionChange?: (key: Key | null) => void
enableFiltering: true enableFiltering: true
popoverWidth?: string
} }

View File

@@ -6053,6 +6053,7 @@ __metadata:
libphonenumber-js: "npm:^1.12.15" libphonenumber-js: "npm:^1.12.15"
motion: "npm:^12.10.0" motion: "npm:^12.10.0"
nuqs: "npm:2.4.3" nuqs: "npm:2.4.3"
react-aria: "npm:^3.39.0"
react-aria-components: "npm:1.8.0" react-aria-components: "npm:1.8.0"
react-day-picker: "npm:^9.6.7" react-day-picker: "npm:^9.6.7"
react-hook-form: "npm:^7.56.2" react-hook-form: "npm:^7.56.2"