import { ReactNode, useLayoutEffect, useRef, useState } from 'react' import type { PlannedLeaveDay } from '@/types' import { useTranslation } from '@/i18n' import { getLabelForType } from '@/utils/leaveTypes' import { datesInRange, formatWeekdayAndDate } from '@/utils/dates' import './band-day-hover-card.css' /** Vertical gap between the card and the slot it describes, and the margin * kept from the viewport edge when clamping horizontally — both in px. */ const ANCHOR_GAP_PX = 6 const VIEWPORT_MARGIN_PX = 8 /** Fallback size used before the card has painted once (`offsetWidth`/ * `offsetHeight` are 0 pre-layout) — matches `band-day-hover-card.css`'s * `max-width`, so the very first frame's guess is already close. */ const FALLBACK_WIDTH_PX = 220 const FALLBACK_HEIGHT_PX = 60 export interface BandDayHoverCardProps { /** DOM id this card is described by (`aria-describedby` on the hovered * slot points at it) — see `CalendarWeek`'s `useId()`-generated id. */ id: string parentName: string date: string /** `null` for a bridged gap day (weekend/red day the band passes through * without a funded row) — see `BandDayInfo.isGap` in CalendarWeek. */ dayType: PlannedLeaveDay['dayType'] | null isGap: boolean isDubbeldag: boolean isPinned: boolean /** The whole period's date range (not just this row's slice of it), so * the card always reports the period's true length. */ periodStart: string periodEnd: string /** The hovered/focused `.calendar-band__day` slot the card describes and * positions itself against. `CalendarWeek` portals this component to * `document.body` (see its render) specifically so the card can escape * `.calendar-week`'s `overflow: hidden` — that clip is deliberate there * (it stops a band bleeding into the next row) but would otherwise also * clip this card, which needs to poke out above the row. Positioning is * computed from `anchorEl`'s real screen position instead of the band's * own grid/percentage math, so it works the same regardless of which * ancestor would otherwise have clipped it, and flips below when hovering * the calendar's very first row leaves no room above the viewport. */ anchorEl: HTMLElement } export function BandDayHoverCard({ id, parentName, date, dayType, isGap, isDubbeldag, isPinned, periodStart, periodEnd, anchorEl, }: BandDayHoverCardProps): ReactNode { const { t, locale } = useTranslation() const cardRef = useRef(null) const [position, setPosition] = useState<{ top: number; left: number } | null>(null) useLayoutEffect(() => { const updatePosition = (): void => { const anchorRect = anchorEl.getBoundingClientRect() const cardWidth = cardRef.current?.offsetWidth || FALLBACK_WIDTH_PX const cardHeight = cardRef.current?.offsetHeight || FALLBACK_HEIGHT_PX // Prefer above the slot; flip below when there isn't room (the // calendar's top week row has nothing above it to clip into anyway). let top = anchorRect.top - cardHeight - ANCHOR_GAP_PX if (top < VIEWPORT_MARGIN_PX) { top = anchorRect.bottom + ANCHOR_GAP_PX } // Center on the slot, then clamp so a day near either edge (the // Sunday column, or the leading Monday when week numbers are off) // never pushes the card off-screen. let left = anchorRect.left + anchorRect.width / 2 - cardWidth / 2 const maxLeft = window.innerWidth - VIEWPORT_MARGIN_PX - cardWidth left = Math.min(Math.max(left, VIEWPORT_MARGIN_PX), Math.max(maxLeft, VIEWPORT_MARGIN_PX)) setPosition({ top, left }) } updatePosition() // A drag-scroll or a window resize while the card is showing would // otherwise leave it pointing at empty space instead of the slot. window.addEventListener('scroll', updatePosition, true) window.addEventListener('resize', updatePosition) return () => { window.removeEventListener('scroll', updatePosition, true) window.removeEventListener('resize', updatePosition) } }, [anchorEl]) const typeLabel = isGap ? t('calendarBand.hover.gapDay') : getLabelForType(dayType ?? 'vacation', t) const dayCount = datesInRange(periodStart, periodEnd).length const periodLabel = t('calendarBand.hover.period', { start: periodStart, end: periodEnd, count: dayCount }) return ( ) }