feat(SW-650): added sticky position hook and store

This commit is contained in:
Erik Tiekstra
2024-10-17 15:28:24 +02:00
parent b0fbf8a9e4
commit 20e3c9a35f
9 changed files with 240 additions and 19 deletions

View File

@@ -1,13 +1,15 @@
"use client"
import { zodResolver } from "@hookform/resolvers/zod"
import { useEffect, useState } from "react"
import { useEffect, useRef, useState } from "react"
import { FormProvider, useForm } from "react-hook-form"
import { dt } from "@/lib/dt"
import { StickyElementNameEnum } from "@/stores/sticky-position"
import Form from "@/components/Forms/BookingWidget"
import { bookingWidgetSchema } from "@/components/Forms/BookingWidget/schema"
import { CloseLargeIcon } from "@/components/Icons"
import useStickyPosition from "@/hooks/useStickyPosition"
import { debounce } from "@/utils/debounce"
import { getFormattedUrlQueryParams } from "@/utils/url"
@@ -28,6 +30,11 @@ export default function BookingWidgetClient({
searchParams,
}: BookingWidgetClientProps) {
const [isOpen, setIsOpen] = useState(false)
const bookingWidgetRef = useRef(null)
useStickyPosition({
ref: bookingWidgetRef,
name: StickyElementNameEnum.BOOKING_WIDGET,
})
const sessionStorageSearchData =
typeof window !== "undefined"
@@ -139,7 +146,11 @@ export default function BookingWidgetClient({
return (
<FormProvider {...methods}>
<section className={styles.container} data-open={isOpen}>
<section
ref={bookingWidgetRef}
className={styles.container}
data-open={isOpen}
>
<button
className={styles.close}
onClick={closeMobileSearch}

View File

@@ -0,0 +1,24 @@
"use client"
import { PropsWithChildren, useRef } from "react"
import { StickyElementNameEnum } from "@/stores/sticky-position"
import useStickyPosition from "@/hooks/useStickyPosition"
import styles from "./mapWithCard.module.css"
export default function MapWithCardWrapper({ children }: PropsWithChildren) {
const mapWithCardRef = useRef<HTMLDivElement>(null)
useStickyPosition({
ref: mapWithCardRef,
name: StickyElementNameEnum.HOTEL_STATIC_MAP,
group: "hotelPage",
})
return (
<div ref={mapWithCardRef} className={styles.mapWithCard}>
{children}
</div>
)
}

View File

@@ -0,0 +1,12 @@
.mapWithCard {
position: sticky;
top: var(--booking-widget-desktop-height);
min-height: 500px; /* Fixed min to not cover the marker with the card */
height: calc(
100vh - var(--main-menu-desktop-height) -
var(--booking-widget-desktop-height)
); /* Full height without the header + booking widget */
max-height: 935px; /* Fixed max according to figma */
overflow: hidden;
width: 100%;
}

View File

@@ -1,12 +1,15 @@
"use client"
import { useRouter } from "next/navigation"
import { useEffect } from "react"
import { useEffect, useRef } from "react"
import { useIntl } from "react-intl"
import { StickyElementNameEnum } from "@/stores/sticky-position"
import Link from "@/components/TempDesignSystem/Link"
import useHash from "@/hooks/useHash"
import useScrollSpy from "@/hooks/useScrollSpy"
import useStickyPosition from "@/hooks/useStickyPosition"
import styles from "./tabNavigation.module.css"
@@ -23,6 +26,12 @@ export default function TabNavigation({
const hash = useHash()
const intl = useIntl()
const router = useRouter()
const tabNavigationRef = useRef<HTMLDivElement>(null)
useStickyPosition({
ref: tabNavigationRef,
name: StickyElementNameEnum.HOTEL_TAB_NAVIGATION,
group: "hotelPage",
})
const tabLinks: { hash: HotelHashValues; text: string }[] = [
{
@@ -71,7 +80,7 @@ export default function TabNavigation({
}, [activeSectionId, router])
return (
<div className={styles.stickyWrapper}>
<div ref={tabNavigationRef} className={styles.stickyWrapper}>
<nav className={styles.tabsContainer}>
{tabLinks.map((link) => {
const isActive =

View File

@@ -73,19 +73,6 @@
background-color: var(--Base-Surface-Primary-light-Normal);
}
.mapWithCard {
position: sticky;
top: var(--booking-widget-desktop-height);
min-height: 500px; /* Fixed min to not cover the marker with the card */
height: calc(
100vh - var(--main-menu-desktop-height) -
var(--booking-widget-desktop-height)
); /* Full height without the header + booking widget */
max-height: 935px; /* Fixed max according to figma */
overflow: hidden;
width: 100%;
}
.pageContainer > nav {
padding-left: var(--Spacing-x5);
padding-right: var(--Spacing-x5);

View File

@@ -12,6 +12,7 @@ import { getRestaurantHeading } from "@/utils/facilityCards"
import DynamicMap from "./Map/DynamicMap"
import MapCard from "./Map/MapCard"
import MapWithCardWrapper from "./Map/MapWithCard"
import MobileMapToggle from "./Map/MobileMapToggle"
import StaticMap from "./Map/StaticMap"
import AmenitiesList from "./AmenitiesList"
@@ -107,10 +108,10 @@ export default async function HotelPage() {
{googleMapsApiKey ? (
<>
<aside className={styles.mapContainer}>
<div className={styles.mapWithCard}>
<MapWithCardWrapper>
<StaticMap coordinates={coordinates} hotelName={hotelName} />
<MapCard hotelName={hotelName} pois={topThreePois} />
</div>
</MapWithCardWrapper>
</aside>
<MobileMapToggle />
<DynamicMap

View File

@@ -0,0 +1,90 @@
"use client"
import { useEffect } from "react"
import useStickyPositionStore, {
StickyElementNameEnum,
} from "@/stores/sticky-position"
interface UseStickyPositionProps {
ref?: React.RefObject<HTMLElement>
name?: StickyElementNameEnum
group?: string
}
/**
* Custom hook to manage sticky positioning of elements within a page.
* This hook registers an element as sticky, calculates its top offset based on
* other registered sticky elements, and updates the element's position dynamically.
*
* @param {UseStickyPositionProps} props - The properties for configuring the hook.
* @param {React.RefObject<HTMLElement>} [props.ref] - A reference to the HTML element that should be sticky. Is optional to allow for other components to only get the height of the sticky elements.
* @param {StickyElementNameEnum} [props.name] - A unique name for the sticky element, used for tracking.
* @param {string} [props.group] - An optional group identifier to make multiple elements share the same top offset. Defaults to the name if not provided.
*
* @returns {Object} An object containing information about the sticky elements.
* @returns {number | null} [returns.currentHeight] - The current height of the registered sticky element, or `null` if not available.
* @returns {Array<StickyElement>} [returns.allElements] - An array containing the heights, names, and groups of all registered sticky elements.
*/
export default function useStickyPosition({
ref,
name,
group,
}: UseStickyPositionProps) {
const {
registerSticky,
unregisterSticky,
stickyElements,
updateHeights,
getAllElements,
} = useStickyPositionStore()
useEffect(() => {
if (ref && name) {
// Register the sticky element with the given ref, name, and group.
// If the group is not provided, it defaults to the value of the name.
// This registration keeps track of the sticky element and its height.
registerSticky(ref, name, group || name)
// Update the heights of all registered sticky elements.
// This ensures that the height information is accurate and up-to-date.
updateHeights()
return () => {
unregisterSticky(ref)
}
}
}, [ref, name, group, registerSticky, unregisterSticky, updateHeights])
useEffect(() => {
if (ref) {
// Find the index of the current sticky element in the array of stickyElements.
// This helps us determine its position relative to other sticky elements.
const index = stickyElements.findIndex((el) => el.ref === ref)
if (index !== -1 && ref.current) {
// Get the group name of the current sticky element.
// This will be used to filter out other elements in the same group.
const currentGroup = stickyElements[index].group
// Calculate the top offset for the current element.
// We sum the heights of all elements that appear before the current element
// in the stickyElements array, but only if they belong to a different group.
// This ensures that elements in the same group don't stack on top of each other.
const topOffset = stickyElements
.slice(0, index)
.filter((el) => el.group !== currentGroup)
.reduce((acc, el) => acc + el.height, 0)
// Apply the calculated top offset to the current element's style.
// This positions the element at the correct location within the document.
ref.current.style.top = `${topOffset}px`
}
}
}, [stickyElements, ref])
return {
currentHeight: ref?.current?.offsetHeight || null,
allElements: getAllElements(),
}
}

79
stores/sticky-position.ts Normal file
View File

@@ -0,0 +1,79 @@
import { create } from "zustand"
export enum StickyElementNameEnum {
SITEWIDE_ALERT = "SITEWIDE_ALERT",
BOOKING_WIDGET = "BOOKING_WIDGET",
HOTEL_TAB_NAVIGATION = "HOTEL_TAB_NAVIGATION",
HOTEL_STATIC_MAP = "HOTEL_STATIC_MAP",
}
export interface StickyElement {
height: number
ref: React.RefObject<HTMLElement>
group: string
priority: number
name: StickyElementNameEnum
}
interface StickyStore {
stickyElements: StickyElement[]
registerSticky: (
ref: React.RefObject<HTMLElement>,
name: StickyElementNameEnum,
group: string
) => void
unregisterSticky: (ref: React.RefObject<HTMLElement>) => void
updateHeights: () => void
getAllElements: () => Array<StickyElement>
}
// Map to define priorities based on StickyElementNameEnum
const priorityMap: Record<StickyElementNameEnum, number> = {
[StickyElementNameEnum.SITEWIDE_ALERT]: 1,
[StickyElementNameEnum.BOOKING_WIDGET]: 2,
[StickyElementNameEnum.HOTEL_TAB_NAVIGATION]: 3,
[StickyElementNameEnum.HOTEL_STATIC_MAP]: 3,
}
const useStickyPositionStore = create<StickyStore>((set, get) => ({
stickyElements: [],
registerSticky: (ref, name, group) => {
const priority = priorityMap[name] || 0
set((state) => {
const newStickyElement: StickyElement = {
height: ref.current?.offsetHeight || 0,
ref,
group,
priority,
name,
}
const updatedStickyElements = [
...state.stickyElements,
newStickyElement,
].sort((a, b) => a.priority - b.priority)
return {
stickyElements: updatedStickyElements,
}
})
},
unregisterSticky: (ref) => {
set((state) => ({
stickyElements: state.stickyElements.filter((el) => el.ref !== ref),
}))
},
updateHeights: () => {
set((state) => ({
stickyElements: state.stickyElements.map((el) => ({
...el,
height: el.ref.current?.offsetHeight || el.height,
})),
}))
},
getAllElements: () => get().stickyElements,
}))
export default useStickyPositionStore

View File

@@ -0,0 +1,8 @@
import type { Coordinates } from "@/types/components/maps/coordinates"
import type { PointOfInterest } from "@/types/hotel"
export interface MapWithCardWrapperProps {
coordinates: Coordinates
hotelName: string
topThreePois: PointOfInterest[]
}