a14655a831
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>
124 lines
5.1 KiB
TypeScript
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>
|
|
)
|
|
}
|