feat(SW-2227): Implemented new design for room package filters

Approved-by: Arvid Norlin
This commit is contained in:
Erik Tiekstra
2025-04-16 07:07:49 +00:00
parent e956e148bc
commit af182b49d8
10 changed files with 1811 additions and 149 deletions

View File

@@ -41,35 +41,35 @@ export default function SpecialRequests() {
TODO: Hiding because API is not ready for this yet (https://scandichotels.atlassian.net/browse/SW-1497). Add back in when API is ready. TODO: Hiding because API is not ready for this yet (https://scandichotels.atlassian.net/browse/SW-1497). Add back in when API is ready.
<Select <Select
label={intl.formatMessage({ id: "Floor preference" })} label={intl.formatMessage({ defaultMessage: "Floor preference" })}
name="specialRequest.floorPreference" name="specialRequest.floorPreference"
items={[ items={[
noPreferenceItem, noPreferenceItem,
{ {
value: FloorPreference.HIGH, value: FloorPreference.HIGH,
label: intl.formatMessage({ id: "High floor" }), label: intl.formatMessage({ defaultMessage: "High floor" }),
}, },
{ {
value: FloorPreference.LOW, value: FloorPreference.LOW,
label: intl.formatMessage({ id: "Low floor" }), label: intl.formatMessage({ defaultMessage: "Low floor" }),
}, },
]} ]}
/> />
<Select <Select
label={intl.formatMessage({ id: "Elevator preference" })} label={intl.formatMessage({ defaultMessage: "Elevator preference" })}
name="specialRequest.elevatorPreference" name="specialRequest.elevatorPreference"
items={[ items={[
noPreferenceItem, noPreferenceItem,
{ {
value: ElevatorPreference.AWAY_FROM_ELEVATOR, value: ElevatorPreference.AWAY_FROM_ELEVATOR,
label: intl.formatMessage({ label: intl.formatMessage({
id: "Away from elevator", defaultMessage: "Away from elevator",
}), }),
}, },
{ {
value: ElevatorPreference.NEAR_ELEVATOR, value: ElevatorPreference.NEAR_ELEVATOR,
label: intl.formatMessage({ label: intl.formatMessage({
id: "Near elevator", defaultMessage: "Near elevator",
}), }),
}, },
]} ]}

View File

@@ -1,52 +1,76 @@
.checkboxGroup {
display: grid;
gap: var(--Space-x15);
}
.checkboxWrapper { .checkboxWrapper {
display: grid;
gap: var(--Space-x05);
}
.checkboxField {
display: grid; display: grid;
grid-template-columns: auto 1fr auto; grid-template-columns: auto 1fr auto;
align-items: center; align-items: center;
gap: var(--Spacing-x-one-and-half); gap: var(--Space-x15);
padding: var(--Spacing-x1) var(--Spacing-x-one-and-half); padding: var(--Space-x1) var(--Space-x15);
cursor: pointer; cursor: pointer;
border-radius: var(--Corner-radius-Medium); border-radius: var(--Corner-radius-Medium);
transition: background-color 0.3s; transition: background-color 0.3s;
color: var(--Text-Default); color: var(--Text-Default);
}
.checkboxWrapper:hover { &[data-disabled] {
background-color: var(--UI-Input-Controls-Surface-Hover); cursor: unset;
.checkbox {
border-color: var(--Border-Interactive-Disabled);
background-color: var(--Surface-UI-Fill-Disabled);
}
.text {
color: var(--Base-Text-Disabled);
}
}
&:hover:not([data-disabled]) {
background-color: var(--UI-Input-Controls-Surface-Hover);
}
&[data-focus-visible] .checkbox {
/* Used this value as it makes sense from a token name perspective and has a good contrast, but we need to decide for a default ui state */
outline: 2px solid var(--Border-Interactive-Focus);
outline-offset: 2px;
}
&[data-selected] .checkbox {
border-color: var(--Surface-UI-Fill-Active);
background-color: var(--Surface-UI-Fill-Active);
}
} }
.checkbox { .checkbox {
width: 24px; width: 24px;
height: 24px; height: 24px;
min-width: 24px; min-width: 24px;
border: 1px solid var(--UI-Input-Controls-Border-Normal); border: 1px solid var(--Border-Interactive-Default);
border-radius: var(--Corner-radius-Small); border-radius: var(--Corner-radius-Small);
transition: all 0.3s; transition: all 0.3s;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
background-color: var(--UI-Input-Controls-Surface-Normal); background-color: var(--Surface-UI-Fill-Default);
} }
.checkboxWrapper[data-selected] .checkbox { .text {
border-color: var(--UI-Input-Controls-Fill-Selected); color: var(--Text-Default);
background-color: var(--UI-Input-Controls-Fill-Selected);
}
.checkboxWrapper[data-disabled] .checkbox {
border-color: var(--UI-Input-Controls-Border-Disabled);
background-color: var(--UI-Input-Controls-Surface-Disabled);
}
.checkboxWrapper[data-disabled] .text {
color: var(--Base-Text-Disabled);
} }
@media screen and (max-width: 767px) { @media screen and (max-width: 767px) {
.checkboxWrapper:hover { .checkboxField:hover:not([data-disabled]) {
background-color: transparent; background-color: transparent;
} }
.checkboxWrapper[data-selected] { .checkboxField[data-selected] {
background-color: transparent; background-color: transparent;
} }
} }

View File

@@ -1,5 +1,4 @@
"use client" "use client"
import { Fragment } from "react"
import { Checkbox, CheckboxGroup } from "react-aria-components" import { Checkbox, CheckboxGroup } from "react-aria-components"
import { Controller, useFormContext } from "react-hook-form" import { Controller, useFormContext } from "react-hook-form"
@@ -32,46 +31,44 @@ export default function Checkboxes() {
const allergyRoomSelected = includesAllergyRoom(field.value) const allergyRoomSelected = includesAllergyRoom(field.value)
const petRoomSelected = includesPetRoom(field.value) const petRoomSelected = includesPetRoom(field.value)
return ( return (
<CheckboxGroup {...field}> <CheckboxGroup {...field} className={styles.checkboxGroup}>
<div> {packageOptions.map((option) => {
{packageOptions.map((option) => { const isAllergyRoom = checkIsAllergyRoom(option.code)
const isAllergyRoom = checkIsAllergyRoom(option.code) const isPetRoom = checkIsPetRoom(option.code)
const isPetRoom = checkIsPetRoom(option.code) const isDisabled =
const isDisabled = (isPetRoom && allergyRoomSelected) ||
(isPetRoom && allergyRoomSelected) || (isAllergyRoom && petRoomSelected)
(isAllergyRoom && petRoomSelected)
const isSelected = field.value.includes(option.code) const isSelected = field.value.includes(option.code)
const iconName = getIconNameByPackageCode(option.code) const iconName = getIconNameByPackageCode(option.code)
return ( return (
<Fragment key={option.code}> <div key={option.code} className={styles.checkboxWrapper}>
<Checkbox <Checkbox
key={option.code} key={option.code}
className={styles.checkboxWrapper} className={styles.checkboxField}
isDisabled={isDisabled} isDisabled={isDisabled}
value={option.code} value={option.code}
> >
<span className={styles.checkbox}> <span className={styles.checkbox}>
{isSelected ? ( {isSelected ? (
<MaterialIcon icon="check" color="Icon/Inverted" /> <MaterialIcon icon="check" color="Icon/Inverted" />
) : null}
</span>
<Typography
className={styles.text}
variant="Body/Paragraph/mdRegular"
>
<span>{option.description}</span>
</Typography>
{iconName ? (
<MaterialIcon icon={iconName} color="Icon/Default" />
) : null} ) : null}
</Checkbox> </span>
{isPetRoom ? <PetRoomMessage /> : null} <Typography
</Fragment> className={styles.text}
) variant="Body/Paragraph/mdRegular"
})} >
</div> <span>{option.description}</span>
</Typography>
{iconName ? (
<MaterialIcon icon={iconName} color="Icon/Default" />
) : null}
</Checkbox>
{isPetRoom ? <PetRoomMessage /> : null}
</div>
)
})}
</CheckboxGroup> </CheckboxGroup>
) )
}} }}

View File

@@ -1,11 +1,30 @@
.footer { .footer {
display: grid;
gap: var(--Space-x1);
padding: 0 var(--Space-x15); padding: 0 var(--Space-x15);
} }
.buttonContainer { .buttonContainer {
align-items: center;
display: flex; display: flex;
justify-content: space-between; flex-direction: column;
gap: var(--Space-x1);
}
.divider {
margin: var(--Space-x15) 0;
}
@media screen and (max-width: 767px) {
.divider {
display: none;
}
.footer {
margin-top: var(--Space-x5);
}
}
@media screen and (min-width: 768px) {
.buttonContainer {
flex-direction: row-reverse;
justify-content: space-between;
align-items: center;
}
} }

View File

@@ -74,8 +74,13 @@ export default function Form({ close }: { close: VoidFunction }) {
<form onSubmit={methods.handleSubmit(onSubmit)}> <form onSubmit={methods.handleSubmit(onSubmit)}>
<Checkboxes /> <Checkboxes />
<div className={styles.footer}> <div className={styles.footer}>
<Divider color="borderDividerSubtle" /> <Divider color="borderDividerSubtle" className={styles.divider} />
<div className={styles.buttonContainer}> <div className={styles.buttonContainer}>
<Typography variant="Body/Supporting text (caption)/smBold">
<Button variant="Tertiary" size="Small" type="submit">
{intl.formatMessage({ defaultMessage: "Apply" })}
</Button>
</Typography>
<Typography variant="Body/Supporting text (caption)/smBold"> <Typography variant="Body/Supporting text (caption)/smBold">
<Button <Button
onPress={clearSelectedPackages} onPress={clearSelectedPackages}
@@ -87,13 +92,6 @@ export default function Form({ close }: { close: VoidFunction }) {
})} })}
</Button> </Button>
</Typography> </Typography>
<Typography variant="Body/Supporting text (caption)/smBold">
<Button variant="Tertiary" size="Small" type="submit">
{intl.formatMessage({
defaultMessage: "Apply",
})}
</Button>
</Typography>
</div> </div>
</div> </div>
</form> </form>

View File

@@ -0,0 +1,52 @@
import { useState } from "react"
import {
Dialog,
DialogTrigger,
Modal,
ModalOverlay,
} from "react-aria-components"
import { useIntl } from "react-intl"
import { Button } from "@scandic-hotels/design-system/Button"
import { ChipButton } from "@scandic-hotels/design-system/ChipButton"
import { MaterialIcon } from "@scandic-hotels/design-system/Icons/MaterialIcon"
import { Typography } from "@scandic-hotels/design-system/Typography"
import Form from "./Form"
import styles from "./roomPackageFilter.module.css"
export default function RoomPackageFilterModal() {
const intl = useIntl()
const [isOpen, setIsOpen] = useState(false)
return (
<DialogTrigger isOpen={isOpen} onOpenChange={setIsOpen}>
<ChipButton variant="Outlined">
{intl.formatMessage({ defaultMessage: "Room preferences" })}
<MaterialIcon
icon="keyboard_arrow_down"
size={20}
color="CurrentColor"
/>
</ChipButton>
<ModalOverlay className={styles.modalOverlay} isDismissable>
<Modal className={styles.modal}>
<Dialog className={styles.modalDialog}>
<div className={styles.header}>
<Typography variant="Title/Subtitle/md">
<h3>
{intl.formatMessage({ defaultMessage: "Room preferences" })}
</h3>
</Typography>
<Button variant="Icon" onPress={() => setIsOpen(false)}>
<MaterialIcon icon="close" size={24} color="CurrentColor" />
</Button>
</div>
<Form close={() => setIsOpen(false)} />
</Dialog>
</Modal>
</ModalOverlay>
</DialogTrigger>
)
}

View File

@@ -0,0 +1,33 @@
import { useState } from "react"
import { Dialog, DialogTrigger, Popover } from "react-aria-components"
import { useIntl } from "react-intl"
import { ChipButton } from "@scandic-hotels/design-system/ChipButton"
import { MaterialIcon } from "@scandic-hotels/design-system/Icons/MaterialIcon"
import Form from "./Form"
import styles from "./roomPackageFilter.module.css"
export default function RoomPackageFilterPopover() {
const intl = useIntl()
const [isOpen, setIsOpen] = useState(false)
return (
<DialogTrigger isOpen={isOpen} onOpenChange={setIsOpen}>
<ChipButton variant="Outlined">
{intl.formatMessage({ defaultMessage: "Room preferences" })}
<MaterialIcon
icon="keyboard_arrow_down"
size={20}
color="CurrentColor"
/>
</ChipButton>
<Popover placement="bottom end" className={styles.popover}>
<Dialog className={styles.popoverDialog}>
<Form close={() => setIsOpen(false)} />
</Dialog>
</Popover>
</DialogTrigger>
)
}

View File

@@ -1,15 +1,9 @@
"use client" "use client"
import { useState } from "react" import { Button as AriaButton } from "react-aria-components"
import { import { useMediaQuery } from "usehooks-ts"
Button as AriaButton,
Dialog,
DialogTrigger,
Popover,
} from "react-aria-components"
import { useIntl } from "react-intl"
import { ChipButton } from "@scandic-hotels/design-system/ChipButton"
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 { trpc } from "@/lib/trpc/client" import { trpc } from "@/lib/trpc/client"
import { useRatesStore } from "@/stores/select-rate" import { useRatesStore } from "@/stores/select-rate"
@@ -17,7 +11,8 @@ import { useRatesStore } from "@/stores/select-rate"
import { useRoomContext } from "@/contexts/SelectRate/Room" import { useRoomContext } from "@/contexts/SelectRate/Room"
import useLang from "@/hooks/useLang" import useLang from "@/hooks/useLang"
import Form from "./Form" import RoomPackageFilterModal from "./Modal"
import RoomPackageFilterPopover from "./Popover"
import { getIconNameByPackageCode } from "./utils" import { getIconNameByPackageCode } from "./utils"
import styles from "./roomPackageFilter.module.css" import styles from "./roomPackageFilter.module.css"
@@ -25,11 +20,9 @@ import styles from "./roomPackageFilter.module.css"
import type { PackageEnum } from "@/types/requests/packages" import type { PackageEnum } from "@/types/requests/packages"
export default function RoomPackageFilter() { export default function RoomPackageFilter() {
const intl = useIntl()
const lang = useLang() const lang = useLang()
const utils = trpc.useUtils() const utils = trpc.useUtils()
const displayAsPopover = useMediaQuery("(min-width: 768px)")
const [isOpen, setIsOpen] = useState(false)
const { const {
actions: { removeSelectedPackage, updateRooms }, actions: { removeSelectedPackage, updateRooms },
@@ -63,41 +56,35 @@ export default function RoomPackageFilter() {
return ( return (
<div className={styles.roomPackageFilter}> <div className={styles.roomPackageFilter}>
{selectedPackages.map((pkg) => ( <div className={styles.selectedPackages}>
<AriaButton {selectedPackages.map((pkg) => (
key={pkg.code} <Typography
className={styles.activeFilterButton} key={pkg.code}
onPress={() => deleteSelectedPackage(pkg.code)} variant="Body/Supporting text (caption)/smRegular"
> >
<MaterialIcon <span className={styles.selectedPackage}>
icon={getIconNameByPackageCode(pkg.code)} <MaterialIcon
size={16} icon={getIconNameByPackageCode(pkg.code)}
color="Icon/Interactive/Default" size={16}
/> color="CurrentColor"
<MaterialIcon />
icon="close" {pkg.description}
size={16} <AriaButton
color="Icon/Interactive/Default" onPress={() => deleteSelectedPackage(pkg.code)}
/> className={styles.removeButton}
</AriaButton> >
))} <MaterialIcon icon="close" size={16} color="CurrentColor" />
<DialogTrigger isOpen={isOpen} onOpenChange={setIsOpen}> </AriaButton>
<ChipButton variant="Outlined"> </span>
{intl.formatMessage({ </Typography>
defaultMessage: "Room preferences", ))}
})} </div>
<MaterialIcon <div hidden={displayAsPopover}>
icon="keyboard_arrow_down" <RoomPackageFilterModal />
size={20} </div>
color="CurrentColor" <div hidden={!displayAsPopover}>
/> <RoomPackageFilterPopover />
</ChipButton> </div>
<Popover placement="bottom end">
<Dialog className={styles.dialog}>
<Form close={() => setIsOpen(false)} />
</Dialog>
</Popover>
</DialogTrigger>
</div> </div>
) )
} }

View File

@@ -1,28 +1,141 @@
.roomPackageFilter { .roomPackageFilter {
display: flex; display: flex;
gap: var(--Space-x1); gap: var(--Space-x1);
flex-direction: column-reverse;
align-items: start;
} }
.activeFilterButton { .selectedPackages {
display: flex; display: flex;
justify-content: center; gap: var(--Space-x1);
align-items: center; flex-wrap: wrap;
padding: 0 var(--Space-x1); }
gap: var(--Space-x05);
border-radius: var(--Corner-radius-Small); .modalOverlay {
background-color: var(--Surface-Secondary-Default-dark); position: fixed;
border-width: 0; inset: 0;
cursor: pointer; background-color: var(--Overlay-40);
&[data-entering] {
animation: overlay-fade 200ms;
}
&[data-exiting] {
animation: overlay-fade 150ms reverse ease-in;
}
}
.modal {
position: fixed;
bottom: 0;
left: 0;
right: 0;
padding: var(--Space-x2) var(--Space-x05);
border-radius: var(--Corner-radius-md) var(--Corner-radius-md) 0 0;
background-color: var(--Surface-Primary-Default);
box-shadow: 0px 0px 14px 6px rgba(0, 0, 0, 0.1);
&[data-entering] {
animation: modal-anim 200ms;
}
&[data-exiting] {
animation: modal-anim 150ms reverse ease-in;
}
}
.modalDialog {
display: grid;
gap: var(--Space-x2);
} }
.dialog { .dialog {
display: grid; display: grid;
gap: var(--Space-x1); gap: var(--Space-x2);
padding: var(--Space-x2);
flex-direction: column;
align-items: flex-end;
border-radius: var(--Corner-radius-md);
background-color: var(--Surface-Primary-Default);
box-shadow: 0px 0px 14px 6px rgba(0, 0, 0, 0.1);
max-width: 340px; max-width: 340px;
} }
.header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0 var(--Space-x15);
}
.footer {
display: grid;
gap: var(--Space-x1);
padding: 0 var(--Space-x15);
}
.selectedPackage {
display: flex;
justify-content: center;
align-items: center;
padding: var(--Space-x1);
gap: var(--Space-x05);
border-radius: var(--Corner-radius-Small);
background-color: var(--Surface-Secondary-Default-dark);
color: var(--Text-Interactive-Default);
}
.removeButton {
background-color: transparent;
border-width: 0;
cursor: pointer;
padding: var(--Space-x05);
margin: calc(-1 * var(--Space-x05));
}
@media screen and (max-width: 767px) {
.popover {
display: none;
}
}
@media screen and (min-width: 768px) {
.roomPackageFilter {
flex-direction: row;
align-items: stretch;
}
.modalOverlay {
display: none;
}
.popover {
padding: var(--Space-x2);
border-radius: var(--Corner-radius-md);
background-color: var(--Surface-Primary-Default);
box-shadow: 0px 0px 14px 6px rgba(0, 0, 0, 0.1);
max-width: 340px;
}
.checkboxContainer {
padding: 0 var(--Space-x1);
}
.header {
display: none;
}
}
@keyframes overlay-fade {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes modal-anim {
from {
transform: translateY(100%);
}
to {
transform: translateY(0);
}
}

File diff suppressed because it is too large Load Diff