Files
F-r-ldraledigplannerare/frontend/src/components/calendar/BandDayHoverCard.tsx
T
jacobh2 a14655a831 Label each leave-type change on calendar bands and add a per-day hover card
A band showed one label for its first day's type, so a mid-band type change
had no text and the label ran over dark fills in the wrong ink. Each same-type
run now gets its own label and ink (parent name only on the first), the
lägsta outline covers only lägsta days, and hovering/focusing a day shows a
card with parent, date, type, dubbeldag/pinned flags and the period range.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-23 09:20:01 +02:00

124 lines
5.1 KiB
TypeScript

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<HTMLDivElement>(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 (
<div
ref={cardRef}
id={id}
role="tooltip"
className="band-day-hover-card"
style={{
top: `${position?.top ?? -9999}px`,
left: `${position?.left ?? -9999}px`,
}}
>
<div className="band-day-hover-card__header">
<span className="band-day-hover-card__parent">{parentName}</span>
<span className="band-day-hover-card__date">{formatWeekdayAndDate(date, locale)}</span>
</div>
<div className="band-day-hover-card__type">{typeLabel}</div>
{isDubbeldag && <div className="band-day-hover-card__flag">{t('calendarBand.hover.dubbeldag')}</div>}
{isPinned && <div className="band-day-hover-card__flag">{t('calendarDay.pinnedAria')}</div>}
<div className="band-day-hover-card__period">{periodLabel}</div>
</div>
)
}