feat(SW-650): added sticky position hook and store
This commit is contained in:
@@ -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}
|
||||
|
||||
24
components/ContentType/HotelPage/Map/MapWithCard/index.tsx
Normal file
24
components/ContentType/HotelPage/Map/MapWithCard/index.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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%;
|
||||
}
|
||||
@@ -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 =
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
90
hooks/useStickyPosition.ts
Normal file
90
hooks/useStickyPosition.ts
Normal 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
79
stores/sticky-position.ts
Normal 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
|
||||
8
types/components/hotelPage/map/mapWithCardWrapper.ts
Normal file
8
types/components/hotelPage/map/mapWithCardWrapper.ts
Normal 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[]
|
||||
}
|
||||
Reference in New Issue
Block a user