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>
This commit is contained in:
@@ -0,0 +1,79 @@
|
|||||||
|
import { describe, it, expect } from 'vitest'
|
||||||
|
import { render, screen } from '@testing-library/react'
|
||||||
|
import { BandDayHoverCard } from './BandDayHoverCard'
|
||||||
|
|
||||||
|
// No i18n mock here (unlike CalendarWeek.test.tsx) — this exercises the real
|
||||||
|
// catalogue/interpolation, since the card's whole job is composing several
|
||||||
|
// translated fragments (type, flags, period range) into one small tooltip.
|
||||||
|
// tests/setup.ts pins jsdom's navigator.language to sv-SE, so this renders
|
||||||
|
// the real Swedish copy.
|
||||||
|
|
||||||
|
describe('BandDayHoverCard', () => {
|
||||||
|
const baseProps = {
|
||||||
|
id: 'hover-card-1',
|
||||||
|
parentName: 'Alice',
|
||||||
|
date: '2026-06-05',
|
||||||
|
isGap: false,
|
||||||
|
isDubbeldag: false,
|
||||||
|
isPinned: false,
|
||||||
|
periodStart: '2026-06-01',
|
||||||
|
periodEnd: '2026-06-07',
|
||||||
|
// A detached element is enough for content assertions — jsdom has no
|
||||||
|
// real layout engine, so `getBoundingClientRect()` on it (or on any
|
||||||
|
// attached element) returns an all-zero rect either way. Positioning
|
||||||
|
// itself is this component's own concern and isn't asserted here.
|
||||||
|
anchorEl: document.createElement('div'),
|
||||||
|
}
|
||||||
|
|
||||||
|
it('renders as a tooltip with the given id', () => {
|
||||||
|
render(<BandDayHoverCard {...baseProps} dayType="vacation" />)
|
||||||
|
const tooltip = screen.getByRole('tooltip')
|
||||||
|
expect(tooltip).toHaveAttribute('id', 'hover-card-1')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows the parent name and the day type label', () => {
|
||||||
|
render(<BandDayHoverCard {...baseProps} dayType="sjukpenning" />)
|
||||||
|
const tooltip = screen.getByRole('tooltip')
|
||||||
|
expect(tooltip.textContent).toContain('Alice')
|
||||||
|
expect(tooltip.textContent).toContain('Sjukpenningnivå')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows the weekday and date', () => {
|
||||||
|
render(<BandDayHoverCard {...baseProps} dayType="vacation" date="2026-06-05" />)
|
||||||
|
// 2026-06-05 is a Friday.
|
||||||
|
expect(screen.getByRole('tooltip').textContent).toContain('fredag')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows a gap-day explanation instead of a funding type for a bridged gap day', () => {
|
||||||
|
render(<BandDayHoverCard {...baseProps} dayType={null} isGap={true} />)
|
||||||
|
const tooltip = screen.getByRole('tooltip')
|
||||||
|
expect(tooltip.textContent).not.toContain('Semester')
|
||||||
|
expect(tooltip.textContent).not.toContain('Sjukpenningnivå')
|
||||||
|
expect(tooltip.textContent).toContain('Helg')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows the dubbeldag flag only when the day is a dubbeldag', () => {
|
||||||
|
const { rerender } = render(<BandDayHoverCard {...baseProps} dayType="vacation" isDubbeldag={false} />)
|
||||||
|
expect(screen.queryByText('Dubbeldag')).not.toBeInTheDocument()
|
||||||
|
|
||||||
|
rerender(<BandDayHoverCard {...baseProps} dayType="vacation" isDubbeldag={true} />)
|
||||||
|
expect(screen.getByText('Dubbeldag')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows the pinned flag (reusing calendarDay.pinnedAria wording) only when pinned', () => {
|
||||||
|
const { rerender } = render(<BandDayHoverCard {...baseProps} dayType="vacation" isPinned={false} />)
|
||||||
|
expect(screen.queryByText(/låst av dig/)).not.toBeInTheDocument()
|
||||||
|
|
||||||
|
rerender(<BandDayHoverCard {...baseProps} dayType="vacation" isPinned={true} />)
|
||||||
|
expect(screen.getByText(/låst av dig/)).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows the period range and day count', () => {
|
||||||
|
render(<BandDayHoverCard {...baseProps} dayType="vacation" periodStart="2026-06-01" periodEnd="2026-06-07" />)
|
||||||
|
const tooltip = screen.getByRole('tooltip')
|
||||||
|
expect(tooltip.textContent).toContain('2026-06-01')
|
||||||
|
expect(tooltip.textContent).toContain('2026-06-07')
|
||||||
|
// Inclusive range: 7 days.
|
||||||
|
expect(tooltip.textContent).toContain('7')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
/* Calendar band day hover/focus card — a small floating tooltip describing
|
||||||
|
* the hovered/focused day slot inside a band (see CalendarWeek's `hoveredDay`
|
||||||
|
* state and BandDayHoverCard). `position: fixed` with `top`/`left` computed
|
||||||
|
* in BandDayHoverCard.tsx from the slot's real screen position, and the
|
||||||
|
* component is portalled to `document.body` by CalendarWeek — NOT rendered
|
||||||
|
* inside `.calendar-week`/`.calendar-band`, both of which clip via
|
||||||
|
* `overflow: hidden` (deliberately, so a band never bleeds into the next
|
||||||
|
* row — see calendar.css). A percentage/grid position relative to either
|
||||||
|
* ancestor would be clipped the moment the card pokes above the row, which
|
||||||
|
* is most of the time (a band sits near the bottom of its cell); the fixed
|
||||||
|
* position plus portal is what actually escapes that, in every row
|
||||||
|
* including the calendar's first one. Surface/shadow mirror the modal shell
|
||||||
|
* (`--c-surface`/`--shadow-float`, see primitives.css) since this is the
|
||||||
|
* app's other floating layer, just far smaller and non-interactive. Sized
|
||||||
|
* to content (bilingual rule — Swedish strings run longer than English). */
|
||||||
|
.band-day-hover-card {
|
||||||
|
position: fixed;
|
||||||
|
z-index: var(--z-popover);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
width: max-content;
|
||||||
|
max-width: 220px;
|
||||||
|
padding: var(--sp-2) var(--sp-3);
|
||||||
|
background-color: var(--c-surface);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
box-shadow: var(--shadow-float);
|
||||||
|
color: var(--c-text);
|
||||||
|
font-size: var(--fs-xs);
|
||||||
|
line-height: 1.35;
|
||||||
|
pointer-events: none;
|
||||||
|
white-space: normal;
|
||||||
|
}
|
||||||
|
|
||||||
|
.band-day-hover-card__header {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.band-day-hover-card__parent {
|
||||||
|
color: var(--c-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.band-day-hover-card__date {
|
||||||
|
font-weight: 400;
|
||||||
|
color: var(--c-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.band-day-hover-card__type {
|
||||||
|
color: var(--c-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.band-day-hover-card__flag {
|
||||||
|
color: var(--c-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.band-day-hover-card__period {
|
||||||
|
margin-top: 2px;
|
||||||
|
padding-top: 2px;
|
||||||
|
border-top: 1px solid var(--c-border);
|
||||||
|
color: var(--c-text-muted);
|
||||||
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||||
import { render, screen, fireEvent } from '@testing-library/react'
|
import { render, screen, fireEvent, act } from '@testing-library/react'
|
||||||
import { CalendarWeek } from './CalendarWeek'
|
import { CalendarWeek, computeTypeRuns } from './CalendarWeek'
|
||||||
import type { BandClickInfo } from './CalendarWeek'
|
import type { BandClickInfo, BandDayInfo } from './CalendarWeek'
|
||||||
import type { PlannedLeaveDay, CalendarDayEntry } from '@/types'
|
import type { PlannedLeaveDay, CalendarDayEntry } from '@/types'
|
||||||
import type { CalendarDayProps } from './CalendarDay'
|
import type { CalendarDayProps } from './CalendarDay'
|
||||||
import type { CalendarEventProps } from './CalendarEvent'
|
import type { CalendarEventProps } from './CalendarEvent'
|
||||||
@@ -15,9 +15,13 @@ vi.mock('@/i18n', () => ({
|
|||||||
'leaveType.sjukpenning': 'Sjukpenning',
|
'leaveType.sjukpenning': 'Sjukpenning',
|
||||||
'leaveType.lagsta': 'Lägsta nivå',
|
'leaveType.lagsta': 'Lägsta nivå',
|
||||||
'calendarDay.pinnedAria': 'pinned',
|
'calendarDay.pinnedAria': 'pinned',
|
||||||
|
'calendarBand.hover.gapDay': 'Weekend or public holiday',
|
||||||
|
'calendarBand.hover.dubbeldag': 'Double day',
|
||||||
|
'calendarBand.hover.period': 'period range',
|
||||||
}
|
}
|
||||||
return translations[key] ?? key
|
return translations[key] ?? key
|
||||||
},
|
},
|
||||||
|
locale: 'sv',
|
||||||
}),
|
}),
|
||||||
}))
|
}))
|
||||||
|
|
||||||
@@ -87,6 +91,16 @@ function calDay(overrides: Partial<CalendarDayEntry> & Pick<CalendarDayEntry, 'd
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function bandDayInfo(overrides: Partial<BandDayInfo> & Pick<BandDayInfo, 'date'>): BandDayInfo {
|
||||||
|
return {
|
||||||
|
dayType: 'vacation',
|
||||||
|
isDubbeldag: false,
|
||||||
|
isPinned: false,
|
||||||
|
isGap: false,
|
||||||
|
...overrides,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
describe('CalendarWeek', () => {
|
describe('CalendarWeek', () => {
|
||||||
const defaultProps = {
|
const defaultProps = {
|
||||||
weekDates: ['2026-01-05', '2026-01-06', '2026-01-07', '2026-01-08', '2026-01-09', '2026-01-10', '2026-01-11'],
|
weekDates: ['2026-01-05', '2026-01-06', '2026-01-07', '2026-01-08', '2026-01-09', '2026-01-10', '2026-01-11'],
|
||||||
@@ -1013,4 +1027,225 @@ describe('CalendarWeek', () => {
|
|||||||
expect(cell).toHaveAttribute('data-is-override', 'false')
|
expect(cell).toHaveAttribute('data-is-override', 'false')
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe('computeTypeRuns', () => {
|
||||||
|
it('returns one run spanning the single day', () => {
|
||||||
|
const runs = computeTypeRuns([bandDayInfo({ date: '2026-01-05', dayType: 'vacation' })])
|
||||||
|
expect(runs).toEqual([{ typeKey: 'vacation', dayType: 'vacation', startIdx: 0, endIdx: 0 }])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('splits into one run per day when every day has a different type (alternating types)', () => {
|
||||||
|
const runs = computeTypeRuns([
|
||||||
|
bandDayInfo({ date: '2026-01-05', dayType: 'vacation' }),
|
||||||
|
bandDayInfo({ date: '2026-01-06', dayType: 'sjukpenning' }),
|
||||||
|
bandDayInfo({ date: '2026-01-07', dayType: 'lagsta' }),
|
||||||
|
])
|
||||||
|
expect(runs).toEqual([
|
||||||
|
{ typeKey: 'vacation', dayType: 'vacation', startIdx: 0, endIdx: 0 },
|
||||||
|
{ typeKey: 'sjukpenning', dayType: 'sjukpenning', startIdx: 1, endIdx: 1 },
|
||||||
|
{ typeKey: 'lagsta', dayType: 'lagsta', startIdx: 2, endIdx: 2 },
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('absorbs leading gaps into the first run', () => {
|
||||||
|
const runs = computeTypeRuns([
|
||||||
|
bandDayInfo({ date: '2026-01-03', isGap: true, dayType: null }),
|
||||||
|
bandDayInfo({ date: '2026-01-04', isGap: true, dayType: null }),
|
||||||
|
bandDayInfo({ date: '2026-01-05', dayType: 'vacation' }),
|
||||||
|
])
|
||||||
|
expect(runs).toEqual([{ typeKey: 'vacation', dayType: 'vacation', startIdx: 0, endIdx: 2 }])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('keeps a gap between two same-type days inside one run (Friday-to-Monday)', () => {
|
||||||
|
const runs = computeTypeRuns([
|
||||||
|
bandDayInfo({ date: '2026-01-09', dayType: 'vacation' }),
|
||||||
|
bandDayInfo({ date: '2026-01-10', isGap: true, dayType: null }),
|
||||||
|
bandDayInfo({ date: '2026-01-11', isGap: true, dayType: null }),
|
||||||
|
bandDayInfo({ date: '2026-01-12', dayType: 'vacation' }),
|
||||||
|
])
|
||||||
|
expect(runs).toEqual([{ typeKey: 'vacation', dayType: 'vacation', startIdx: 0, endIdx: 3 }])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('ends a run at a gap that is followed by a different type, joining the gap to the earlier run', () => {
|
||||||
|
const runs = computeTypeRuns([
|
||||||
|
bandDayInfo({ date: '2026-01-09', dayType: 'vacation' }),
|
||||||
|
bandDayInfo({ date: '2026-01-10', isGap: true, dayType: null }),
|
||||||
|
bandDayInfo({ date: '2026-01-11', isGap: true, dayType: null }),
|
||||||
|
bandDayInfo({ date: '2026-01-12', dayType: 'sjukpenning' }),
|
||||||
|
])
|
||||||
|
expect(runs).toEqual([
|
||||||
|
{ typeKey: 'vacation', dayType: 'vacation', startIdx: 0, endIdx: 2 },
|
||||||
|
{ typeKey: 'sjukpenning', dayType: 'sjukpenning', startIdx: 3, endIdx: 3 },
|
||||||
|
])
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('mixed-type bands: one label per run, not one band-wide label', () => {
|
||||||
|
it('renders one label per same-type run in a band whose funding type changes mid-period', () => {
|
||||||
|
const plannedDays = [
|
||||||
|
pd({ id: '1', date: '2026-01-05', wishId: 'wish-1', dayType: 'vacation' }),
|
||||||
|
pd({ id: '2', date: '2026-01-06', wishId: 'wish-1', dayType: 'sjukpenning' }),
|
||||||
|
pd({ id: '3', date: '2026-01-07', wishId: 'wish-1', dayType: 'lagsta' }),
|
||||||
|
]
|
||||||
|
const { container } = render(<CalendarWeek {...defaultProps} plannedDays={plannedDays} parentName="Alice" />)
|
||||||
|
const labels = container.querySelectorAll('.calendar-band__label')
|
||||||
|
expect(labels).toHaveLength(3)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('gives only the first run the parent name; later runs show the type alone', () => {
|
||||||
|
const plannedDays = [
|
||||||
|
pd({ id: '1', date: '2026-01-05', wishId: 'wish-1', dayType: 'vacation' }),
|
||||||
|
pd({ id: '2', date: '2026-01-06', wishId: 'wish-1', dayType: 'sjukpenning' }),
|
||||||
|
]
|
||||||
|
const { container } = render(<CalendarWeek {...defaultProps} plannedDays={plannedDays} parentName="Alice" />)
|
||||||
|
const labels = Array.from(container.querySelectorAll('.calendar-band__label'))
|
||||||
|
expect(labels[0]?.textContent).toContain('Alice')
|
||||||
|
expect(labels[1]?.textContent).not.toContain('Alice')
|
||||||
|
expect(labels[1]?.textContent).toContain('Sjukpenning')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('inks each run label from its own type, not the band\'s first day', () => {
|
||||||
|
const plannedDays = [
|
||||||
|
pd({ id: '1', date: '2026-01-05', wishId: 'wish-1', dayType: 'vacation' }),
|
||||||
|
pd({ id: '2', date: '2026-01-06', wishId: 'wish-1', dayType: 'sjukpenning' }),
|
||||||
|
]
|
||||||
|
const { container } = render(<CalendarWeek {...defaultProps} plannedDays={plannedDays} />)
|
||||||
|
const labels = Array.from(container.querySelectorAll<HTMLElement>('.calendar-band__label'))
|
||||||
|
expect(labels[0]?.style.color).toBe('var(--c-leave-vacation-ink)')
|
||||||
|
expect(labels[1]?.style.color).toBe('var(--c-leave-sjukpenning-ink)')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('keeps a same-type run spanning a weekend as one label, not split by the bridged gap', () => {
|
||||||
|
const plannedDays = [
|
||||||
|
pd({ id: '1', date: '2026-01-09', wishId: 'wish-1', dayType: 'vacation' }),
|
||||||
|
pd({ id: '2', date: '2026-01-12', wishId: 'wish-1', dayType: 'vacation' }),
|
||||||
|
]
|
||||||
|
const calendarDaysByDate = new Map<string, CalendarDayEntry>([
|
||||||
|
['2026-01-10', calDay({ date: '2026-01-10', dayType: 'weekend' })],
|
||||||
|
['2026-01-11', calDay({ date: '2026-01-11', dayType: 'weekend' })],
|
||||||
|
])
|
||||||
|
const { container } = render(
|
||||||
|
<CalendarWeek {...defaultProps} plannedDays={plannedDays} calendarDaysByDate={calendarDaysByDate} />,
|
||||||
|
)
|
||||||
|
expect(container.querySelectorAll('.calendar-band__label')).toHaveLength(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('marks only the lägsta run with the outline overlay, not the whole mixed band', () => {
|
||||||
|
const plannedDays = [
|
||||||
|
pd({ id: '1', date: '2026-01-05', wishId: 'wish-1', dayType: 'vacation' }),
|
||||||
|
pd({ id: '2', date: '2026-01-06', wishId: 'wish-1', dayType: 'lagsta' }),
|
||||||
|
pd({ id: '3', date: '2026-01-07', wishId: 'wish-1', dayType: 'sjukpenning' }),
|
||||||
|
]
|
||||||
|
const { container } = render(<CalendarWeek {...defaultProps} plannedDays={plannedDays} />)
|
||||||
|
const outlines = container.querySelectorAll<HTMLElement>('.calendar-band__run--lagsta')
|
||||||
|
expect(outlines).toHaveLength(1)
|
||||||
|
// 3 equal-width slots: the lägsta day is the middle third.
|
||||||
|
expect(outlines[0]?.style.left).toBe(`${(1 / 3) * 100}%`)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('marks each run\'s own dubbeldag flag rather than flagging the whole band', () => {
|
||||||
|
const plannedDays = [
|
||||||
|
pd({ id: '1', date: '2026-01-05', wishId: 'wish-1', dayType: 'vacation', isDubbeldag: false }),
|
||||||
|
pd({ id: '2', date: '2026-01-06', wishId: 'wish-1', dayType: 'sjukpenning', isDubbeldag: true }),
|
||||||
|
]
|
||||||
|
const { container } = render(<CalendarWeek {...defaultProps} plannedDays={plannedDays} />)
|
||||||
|
const labels = Array.from(container.querySelectorAll('.calendar-band__label'))
|
||||||
|
expect(labels[0]?.querySelector('[data-testid="calendar-event"]')).not.toHaveClass('dubbeldag')
|
||||||
|
expect(labels[1]?.querySelector('[data-testid="calendar-event"]')).toHaveClass('dubbeldag')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('per-day hover/focus card', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.useFakeTimers()
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.useRealTimers()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows nothing until the hover delay elapses, then shows the card for the hovered day', () => {
|
||||||
|
const plannedDays = [pd({ id: '1', date: '2026-01-05', dayType: 'sjukpenning' })]
|
||||||
|
const { container } = render(<CalendarWeek {...defaultProps} plannedDays={plannedDays} parentName="Alice" />)
|
||||||
|
const slot = container.querySelector('.calendar-band__day')!
|
||||||
|
fireEvent.pointerEnter(slot)
|
||||||
|
expect(screen.queryByRole('tooltip')).not.toBeInTheDocument()
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
vi.advanceTimersByTime(150)
|
||||||
|
})
|
||||||
|
|
||||||
|
const tooltip = screen.getByRole('tooltip')
|
||||||
|
expect(tooltip.textContent).toContain('Alice')
|
||||||
|
expect(tooltip.textContent).toContain('Sjukpenning')
|
||||||
|
expect(slot).toHaveAttribute('aria-describedby', tooltip.id)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not show the card while a pointer button is held anywhere on the page (dragging)', () => {
|
||||||
|
const plannedDays = [pd({ id: '1', date: '2026-01-05' })]
|
||||||
|
const { container } = render(<CalendarWeek {...defaultProps} plannedDays={plannedDays} />)
|
||||||
|
const slot = container.querySelector('.calendar-band__day')!
|
||||||
|
// A drag-select starts with a pointerdown that bubbles to the window —
|
||||||
|
// CalendarWeek tracks that globally rather than reading the
|
||||||
|
// pointerenter event's own `buttons` bitmask (jsdom doesn't populate
|
||||||
|
// it), same as a real drag started elsewhere in the grid and dragged
|
||||||
|
// over this slot.
|
||||||
|
fireEvent.pointerDown(slot)
|
||||||
|
fireEvent.pointerEnter(slot)
|
||||||
|
act(() => {
|
||||||
|
vi.advanceTimersByTime(150)
|
||||||
|
})
|
||||||
|
expect(screen.queryByRole('tooltip')).not.toBeInTheDocument()
|
||||||
|
|
||||||
|
fireEvent.pointerUp(slot)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('hides the card on pointer leave', () => {
|
||||||
|
const plannedDays = [pd({ id: '1', date: '2026-01-05' })]
|
||||||
|
const { container } = render(<CalendarWeek {...defaultProps} plannedDays={plannedDays} />)
|
||||||
|
const slot = container.querySelector('.calendar-band__day')!
|
||||||
|
fireEvent.pointerEnter(slot)
|
||||||
|
act(() => {
|
||||||
|
vi.advanceTimersByTime(150)
|
||||||
|
})
|
||||||
|
expect(screen.getByRole('tooltip')).toBeInTheDocument()
|
||||||
|
|
||||||
|
fireEvent.pointerLeave(slot)
|
||||||
|
expect(screen.queryByRole('tooltip')).not.toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows the gap-day text, not a funding type, for a bridged gap slot', () => {
|
||||||
|
const plannedDays = [
|
||||||
|
pd({ id: '1', date: '2026-01-09', wishId: 'wish-1' }),
|
||||||
|
pd({ id: '2', date: '2026-01-12', wishId: 'wish-1' }),
|
||||||
|
]
|
||||||
|
const calendarDaysByDate = new Map<string, CalendarDayEntry>([
|
||||||
|
['2026-01-10', calDay({ date: '2026-01-10', dayType: 'weekend' })],
|
||||||
|
['2026-01-11', calDay({ date: '2026-01-11', dayType: 'weekend' })],
|
||||||
|
])
|
||||||
|
const { container } = render(
|
||||||
|
<CalendarWeek {...defaultProps} plannedDays={plannedDays} calendarDaysByDate={calendarDaysByDate} />,
|
||||||
|
)
|
||||||
|
const gapSlot = container.querySelector('.calendar-band__day--gap')!
|
||||||
|
fireEvent.pointerEnter(gapSlot)
|
||||||
|
act(() => {
|
||||||
|
vi.advanceTimersByTime(150)
|
||||||
|
})
|
||||||
|
expect(screen.getByRole('tooltip').textContent).toContain('Weekend or public holiday')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows the card on focus too, without requiring a pointer', () => {
|
||||||
|
const plannedDays = [pd({ id: '1', date: '2026-01-05' })]
|
||||||
|
const { container } = render(<CalendarWeek {...defaultProps} plannedDays={plannedDays} />)
|
||||||
|
const slot = container.querySelector('.calendar-band__day')!
|
||||||
|
fireEvent.focus(slot)
|
||||||
|
act(() => {
|
||||||
|
vi.advanceTimersByTime(150)
|
||||||
|
})
|
||||||
|
expect(screen.getByRole('tooltip')).toBeInTheDocument()
|
||||||
|
|
||||||
|
fireEvent.blur(slot)
|
||||||
|
expect(screen.queryByRole('tooltip')).not.toBeInTheDocument()
|
||||||
|
})
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,10 +1,17 @@
|
|||||||
import { ReactNode, useMemo } from 'react'
|
import { ReactNode, useEffect, useId, useMemo, useRef, useState } from 'react'
|
||||||
|
import { createPortal } from 'react-dom'
|
||||||
import type { PlannedLeaveDay, CalendarDayEntry } from '@/types'
|
import type { PlannedLeaveDay, CalendarDayEntry } from '@/types'
|
||||||
import { getColorForType, getInkForType, getLabelForType, getTypeKey } from '@/utils/leaveTypes'
|
import { getColorForType, getInkForType, getLabelForType, getTypeKey, type LeaveTypeKey } from '@/utils/leaveTypes'
|
||||||
import { getISOWeekNumber, formatDateLocalISO } from '@/utils/dates'
|
import { getISOWeekNumber, formatDateLocalISO } from '@/utils/dates'
|
||||||
import { useTranslation } from '@/i18n'
|
import { useTranslation } from '@/i18n'
|
||||||
import { CalendarDay } from './CalendarDay'
|
import { CalendarDay } from './CalendarDay'
|
||||||
import { CalendarEvent } from './CalendarEvent'
|
import { CalendarEvent } from './CalendarEvent'
|
||||||
|
import { BandDayHoverCard } from '@/components/calendar/BandDayHoverCard'
|
||||||
|
|
||||||
|
/** Delay before the per-day hover/focus card appears — long enough that a
|
||||||
|
* pointer merely passing through several slots (dragging across the grid,
|
||||||
|
* or just moving the mouse toward something else) never flashes it. */
|
||||||
|
const HOVER_SHOW_DELAY_MS = 150
|
||||||
|
|
||||||
/** Fired when a band (a period of planned days, possibly spanning several
|
/** Fired when a band (a period of planned days, possibly spanning several
|
||||||
* weeks) is clicked. `wishId` is null for an ad-hoc period not tied to a
|
* weeks) is clicked. `wishId` is null for an ad-hoc period not tied to a
|
||||||
@@ -74,7 +81,7 @@ export interface CalendarWeekProps {
|
|||||||
* (`isGap: false`, carrying its own funding type/dubbeldag/pinned flags) or
|
* (`isGap: false`, carrying its own funding type/dubbeldag/pinned flags) or
|
||||||
* a bridged free-day gap (a weekend/red day the band passes through without
|
* a bridged free-day gap (a weekend/red day the band passes through without
|
||||||
* needing a funded row — see `isBridgeableGap`). */
|
* needing a funded row — see `isBridgeableGap`). */
|
||||||
interface BandDayInfo {
|
export interface BandDayInfo {
|
||||||
date: string
|
date: string
|
||||||
dayType: PlannedLeaveDay['dayType'] | null
|
dayType: PlannedLeaveDay['dayType'] | null
|
||||||
isDubbeldag: boolean
|
isDubbeldag: boolean
|
||||||
@@ -82,6 +89,91 @@ interface BandDayInfo {
|
|||||||
isGap: boolean
|
isGap: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** A run of consecutive same-type days inside one band segment's
|
||||||
|
* `dayInfos` — see `computeTypeRuns`. `startIdx`/`endIdx` are inclusive
|
||||||
|
* indices into that segment's `dayInfos` array. */
|
||||||
|
export interface TypeRun {
|
||||||
|
typeKey: LeaveTypeKey
|
||||||
|
dayType: PlannedLeaveDay['dayType'] | null
|
||||||
|
startIdx: number
|
||||||
|
endIdx: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Splits a band segment's per-day info into runs of consecutive same-type
|
||||||
|
* days, so each run can get its own readable label instead of one label
|
||||||
|
* (inked for the first day's type) covering the whole segment.
|
||||||
|
*
|
||||||
|
* A bridged gap day (weekend/red day, `isGap: true`) has no type of its
|
||||||
|
* own, so it joins whichever run it sits next to:
|
||||||
|
* - a gap between two real days of the SAME type stays inside that one run
|
||||||
|
* (a Friday→Monday vacation stretch across a weekend is one run, not
|
||||||
|
* three);
|
||||||
|
* - a gap immediately followed by a real day of a DIFFERENT type joins the
|
||||||
|
* run BEFORE it — the new type starts its own run at the next real day;
|
||||||
|
* - gap(s) with no real day before them yet (this segment is a week row
|
||||||
|
* that opens mid-gap, continuing a band that started earlier in the
|
||||||
|
* previous week) join the first run once it's found, rather than being
|
||||||
|
* orphaned before it.
|
||||||
|
*/
|
||||||
|
export function computeTypeRuns(dayInfos: BandDayInfo[]): TypeRun[] {
|
||||||
|
const runs: TypeRun[] = []
|
||||||
|
|
||||||
|
let i = 0
|
||||||
|
while (i < dayInfos.length && dayInfos[i]?.isGap) i++
|
||||||
|
const hasLeadingGaps = i > 0
|
||||||
|
|
||||||
|
while (i < dayInfos.length) {
|
||||||
|
const info = dayInfos[i]
|
||||||
|
if (!info) {
|
||||||
|
i++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
const dayType = info.dayType
|
||||||
|
const typeKey = getTypeKey(dayType ?? 'vacation')
|
||||||
|
const startIdx = i
|
||||||
|
let endIdx = i
|
||||||
|
i++
|
||||||
|
|
||||||
|
while (i < dayInfos.length) {
|
||||||
|
const next = dayInfos[i]
|
||||||
|
if (!next) break
|
||||||
|
if (next.isGap) {
|
||||||
|
endIdx = i
|
||||||
|
i++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if (next.dayType === dayType) {
|
||||||
|
endIdx = i
|
||||||
|
i++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
runs.push({ typeKey, dayType, startIdx, endIdx })
|
||||||
|
}
|
||||||
|
|
||||||
|
// A leading gap (no real day before it in this segment) belongs to the
|
||||||
|
// first run once one exists — this segment is a continuation row, not a
|
||||||
|
// segment with no type at all (a band's dayInfos always contains at
|
||||||
|
// least one real day; only fully-bridged weeks are pure gaps).
|
||||||
|
const firstRun = runs[0]
|
||||||
|
if (hasLeadingGaps && firstRun) {
|
||||||
|
firstRun.startIdx = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// Defensive fallback for a segment that is entirely bridged gaps (a whole
|
||||||
|
// week bridged by a wish that starts/ends outside it) — not expected in
|
||||||
|
// practice, but rendering nothing would silently drop the row.
|
||||||
|
if (runs.length === 0 && dayInfos.length > 0) {
|
||||||
|
runs.push({ typeKey: 'vacation', dayType: null, startIdx: 0, endIdx: dayInfos.length - 1 })
|
||||||
|
}
|
||||||
|
|
||||||
|
return runs
|
||||||
|
}
|
||||||
|
|
||||||
interface BandSegment {
|
interface BandSegment {
|
||||||
id: string
|
id: string
|
||||||
wishId: string | null
|
wishId: string | null
|
||||||
@@ -299,6 +391,62 @@ export function CalendarWeek({
|
|||||||
monthMaxLaneCount,
|
monthMaxLaneCount,
|
||||||
}: CalendarWeekProps): ReactNode {
|
}: CalendarWeekProps): ReactNode {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
|
const hoverCardId = useId()
|
||||||
|
const [hoveredDay, setHoveredDay] = useState<{ segmentId: string; date: string; anchorEl: HTMLElement } | null>(
|
||||||
|
null,
|
||||||
|
)
|
||||||
|
const hoverTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||||
|
// Tracks whether a pointer button is currently held anywhere on the page —
|
||||||
|
// read (not stored in state) so a drag-select in progress never surfaces
|
||||||
|
// the card, without re-rendering the whole week on every pointerdown/up.
|
||||||
|
const pointerDownRef = useRef(false)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handlePointerDown = (): void => {
|
||||||
|
pointerDownRef.current = true
|
||||||
|
}
|
||||||
|
const handlePointerUp = (): void => {
|
||||||
|
pointerDownRef.current = false
|
||||||
|
}
|
||||||
|
window.addEventListener('pointerdown', handlePointerDown)
|
||||||
|
window.addEventListener('pointerup', handlePointerUp)
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('pointerdown', handlePointerDown)
|
||||||
|
window.removeEventListener('pointerup', handlePointerUp)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(
|
||||||
|
() => () => {
|
||||||
|
if (hoverTimeoutRef.current !== null) clearTimeout(hoverTimeoutRef.current)
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
|
||||||
|
const clearHoverTimeout = (): void => {
|
||||||
|
if (hoverTimeoutRef.current !== null) {
|
||||||
|
clearTimeout(hoverTimeoutRef.current)
|
||||||
|
hoverTimeoutRef.current = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delayed show avoids flicker while a pointer drags across many day
|
||||||
|
// slots; an active drag-select (a pointer button held anywhere) must
|
||||||
|
// never surface the card at all. `anchorEl` (the slot itself) is what the
|
||||||
|
// portalled card positions itself against — see BandDayHoverCard's doc
|
||||||
|
// comment for why it needs the real DOM element rather than a percentage
|
||||||
|
// position handed down from here.
|
||||||
|
const scheduleHoverShow = (segmentId: string, date: string, anchorEl: HTMLElement): void => {
|
||||||
|
clearHoverTimeout()
|
||||||
|
if (pointerDownRef.current) return
|
||||||
|
hoverTimeoutRef.current = setTimeout(() => setHoveredDay({ segmentId, date, anchorEl }), HOVER_SHOW_DELAY_MS)
|
||||||
|
}
|
||||||
|
|
||||||
|
const hideHover = (): void => {
|
||||||
|
clearHoverTimeout()
|
||||||
|
setHoveredDay(null)
|
||||||
|
}
|
||||||
|
|
||||||
const bandSegments = useMemo(
|
const bandSegments = useMemo(
|
||||||
() => computeBandSegments(weekDates, plannedDays, calendarDaysByDate),
|
() => computeBandSegments(weekDates, plannedDays, calendarDaysByDate),
|
||||||
[weekDates, plannedDays, calendarDaysByDate],
|
[weekDates, plannedDays, calendarDaysByDate],
|
||||||
@@ -394,21 +542,18 @@ export function CalendarWeek({
|
|||||||
const firstRealDay = segment.dayInfos.find((d) => !d.isGap)
|
const firstRealDay = segment.dayInfos.find((d) => !d.isGap)
|
||||||
const representativeType = firstRealDay?.dayType ?? 'vacation'
|
const representativeType = firstRealDay?.dayType ?? 'vacation'
|
||||||
const typeLabel = getLabelForType(representativeType, t)
|
const typeLabel = getLabelForType(representativeType, t)
|
||||||
const color = getColorForType(representativeType)
|
|
||||||
const ink = getInkForType(representativeType)
|
|
||||||
const anyDubbeldag = segment.dayInfos.some((d) => !d.isGap && d.isDubbeldag)
|
|
||||||
|
|
||||||
// Type by fill/ink/outline (`--vacation|--sjukpenning|--lagsta`,
|
// Type by fill/ink/outline (`--vacation|--sjukpenning|--lagsta`,
|
||||||
// see the --c-leave-* tokens) and owner by a 2px left cap
|
// see the --c-leave-* tokens) and owner by a 2px left cap
|
||||||
// (`--self`/`--partner`) — never by hue, so the two axes stay
|
// (`--self`/`--partner`) — never by hue, so the two axes stay
|
||||||
// readable side by side and in greyscale.
|
// readable side by side and in greyscale. The band itself only
|
||||||
|
// keeps the owner cap now — a mixed-type band's fill/outline is
|
||||||
|
// drawn per run below (see `runs`), not band-wide from whichever
|
||||||
|
// type happens to come first.
|
||||||
const typeKey = getTypeKey(representativeType)
|
const typeKey = getTypeKey(representativeType)
|
||||||
const classNames = [
|
const classNames = [
|
||||||
'calendar-band',
|
'calendar-band',
|
||||||
`calendar-band--${typeKey}`,
|
`calendar-band--${typeKey}`,
|
||||||
// `leave-swatch--*` (primitives.css) carries the shared
|
|
||||||
// fill/hatch/outline/cap-mark recipe; `unknown` has no recipe.
|
|
||||||
typeKey !== 'unknown' ? `leave-swatch--${typeKey}` : '',
|
|
||||||
segment.parentUuid === personalDataUuid ? 'calendar-band--self' : 'calendar-band--partner',
|
segment.parentUuid === personalDataUuid ? 'calendar-band--self' : 'calendar-band--partner',
|
||||||
segment.parentUuid === personalDataUuid ? 'leave-swatch--cap-self' : 'leave-swatch--cap-partner',
|
segment.parentUuid === personalDataUuid ? 'leave-swatch--cap-self' : 'leave-swatch--cap-partner',
|
||||||
segment.isBandStart ? 'calendar-band--start' : '',
|
segment.isBandStart ? 'calendar-band--start' : '',
|
||||||
@@ -418,6 +563,9 @@ export function CalendarWeek({
|
|||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
.join(' ')
|
.join(' ')
|
||||||
|
|
||||||
|
const dayCount = segment.dayInfos.length
|
||||||
|
const runs = computeTypeRuns(segment.dayInfos)
|
||||||
|
|
||||||
const fireClick = (clickedDate?: string) =>
|
const fireClick = (clickedDate?: string) =>
|
||||||
onBandClick?.({
|
onBandClick?.({
|
||||||
wishId: segment.wishId,
|
wishId: segment.wishId,
|
||||||
@@ -429,6 +577,12 @@ export function CalendarWeek({
|
|||||||
clickedDate: clickedDate ?? segment.overallStart,
|
clickedDate: clickedDate ?? segment.overallStart,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const hoveredInfo =
|
||||||
|
hoveredDay?.segmentId === segment.id
|
||||||
|
? segment.dayInfos.find((d) => d.date === hoveredDay.date)
|
||||||
|
: undefined
|
||||||
|
const hoveredIdx = hoveredInfo ? segment.dayInfos.indexOf(hoveredInfo) : -1
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={segment.id}
|
key={segment.id}
|
||||||
@@ -438,8 +592,6 @@ export function CalendarWeek({
|
|||||||
gridRow: '1',
|
gridRow: '1',
|
||||||
bottom: `${bandBottomInset + segment.lane * (barHeight + laneGap)}px`,
|
bottom: `${bandBottomInset + segment.lane * (barHeight + laneGap)}px`,
|
||||||
height: `${barHeight}px`,
|
height: `${barHeight}px`,
|
||||||
backgroundColor: color,
|
|
||||||
color: ink,
|
|
||||||
}}
|
}}
|
||||||
data-testid={`calendar-band-${segment.id}`}
|
data-testid={`calendar-band-${segment.id}`}
|
||||||
aria-label={`${label} · ${typeLabel}, ${segment.overallStart} – ${segment.overallEnd}`}
|
aria-label={`${label} · ${typeLabel}, ${segment.overallStart} – ${segment.overallEnd}`}
|
||||||
@@ -458,43 +610,120 @@ export function CalendarWeek({
|
|||||||
}
|
}
|
||||||
>
|
>
|
||||||
<div className="calendar-band__days">
|
<div className="calendar-band__days">
|
||||||
{segment.dayInfos.map((info) => (
|
{segment.dayInfos.map((info) => {
|
||||||
<span
|
const isHovered = hoveredDay?.segmentId === segment.id && hoveredDay.date === info.date
|
||||||
key={info.date}
|
return (
|
||||||
className={[
|
<span
|
||||||
'calendar-band__day',
|
key={info.date}
|
||||||
info.isGap ? 'calendar-band__day--gap' : `calendar-band__day--${getTypeKey(info.dayType ?? 'vacation')}`,
|
className={[
|
||||||
info.isPinned ? 'calendar-band__day--pinned' : '',
|
'calendar-band__day',
|
||||||
]
|
info.isGap ? 'calendar-band__day--gap' : `calendar-band__day--${getTypeKey(info.dayType ?? 'vacation')}`,
|
||||||
.filter(Boolean)
|
info.isPinned ? 'calendar-band__day--pinned' : '',
|
||||||
.join(' ')}
|
]
|
||||||
style={info.isGap ? undefined : { backgroundColor: getColorForType(info.dayType ?? 'vacation') }}
|
.filter(Boolean)
|
||||||
title={info.isPinned ? t('calendarDay.pinnedAria') : undefined}
|
.join(' ')}
|
||||||
data-testid={info.isPinned ? 'calendar-band-day-pinned' : undefined}
|
style={info.isGap ? undefined : { backgroundColor: getColorForType(info.dayType ?? 'vacation') }}
|
||||||
// Clicking one day slot opens the same popover as clicking
|
data-testid={info.isPinned ? 'calendar-band-day-pinned' : undefined}
|
||||||
// the band, but tells it WHICH date was clicked — "this
|
tabIndex={onBandClick ? 0 : undefined}
|
||||||
// day" must be able to remove a day in the middle of a
|
aria-describedby={isHovered ? hoverCardId : undefined}
|
||||||
// period, not only its first.
|
// Clicking one day slot opens the same popover as clicking
|
||||||
onClick={
|
// the band, but tells it WHICH date was clicked — "this
|
||||||
onBandClick
|
// day" must be able to remove a day in the middle of a
|
||||||
? (e) => {
|
// period, not only its first.
|
||||||
e.stopPropagation()
|
onClick={
|
||||||
fireClick(info.date)
|
onBandClick
|
||||||
}
|
? (e) => {
|
||||||
: undefined
|
e.stopPropagation()
|
||||||
}
|
fireClick(info.date)
|
||||||
|
}
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
onKeyDown={
|
||||||
|
onBandClick
|
||||||
|
? (e) => {
|
||||||
|
if (e.key === 'Enter' || e.key === ' ') {
|
||||||
|
e.preventDefault()
|
||||||
|
e.stopPropagation()
|
||||||
|
fireClick(info.date)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
// The hover/focus card (below) replaces the old `title`
|
||||||
|
// attribute for a pinned day — a native tooltip and this
|
||||||
|
// card fighting over the same slot just doubles up.
|
||||||
|
onPointerEnter={(e) => scheduleHoverShow(segment.id, info.date, e.currentTarget)}
|
||||||
|
onPointerDown={hideHover}
|
||||||
|
onPointerLeave={hideHover}
|
||||||
|
onFocus={(e) => scheduleHoverShow(segment.id, info.date, e.currentTarget)}
|
||||||
|
onBlur={hideHover}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
{runs
|
||||||
|
.filter((run) => run.typeKey === 'lagsta')
|
||||||
|
.map((run) => (
|
||||||
|
<div
|
||||||
|
key={`lagsta-${run.startIdx}`}
|
||||||
|
className="calendar-band__run calendar-band__run--lagsta"
|
||||||
|
style={{
|
||||||
|
left: `${(run.startIdx / dayCount) * 100}%`,
|
||||||
|
width: `${((run.endIdx - run.startIdx + 1) / dayCount) * 100}%`,
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
{runs.map((run, runIdx) => {
|
||||||
<div className="calendar-band__label">
|
const runDayType = run.dayType ?? 'vacation'
|
||||||
<CalendarEvent
|
const runTypeLabel = getLabelForType(runDayType, t)
|
||||||
leaveType={typeLabel}
|
const runDubbeldag = segment.dayInfos
|
||||||
color={color}
|
.slice(run.startIdx, run.endIdx + 1)
|
||||||
isDubbeldag={anyDubbeldag}
|
.some((d) => !d.isGap && d.isDubbeldag)
|
||||||
parentLabel={label}
|
return (
|
||||||
paintBackground={false}
|
<div
|
||||||
/>
|
key={`label-${run.startIdx}`}
|
||||||
</div>
|
className="calendar-band__label"
|
||||||
|
style={{
|
||||||
|
left: `${(run.startIdx / dayCount) * 100}%`,
|
||||||
|
width: `${((run.endIdx - run.startIdx + 1) / dayCount) * 100}%`,
|
||||||
|
color: getInkForType(runDayType),
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<CalendarEvent
|
||||||
|
leaveType={runTypeLabel}
|
||||||
|
color={getColorForType(runDayType)}
|
||||||
|
isDubbeldag={runDubbeldag}
|
||||||
|
parentLabel={runIdx === 0 ? label : undefined}
|
||||||
|
paintBackground={false}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
{hoveredInfo &&
|
||||||
|
hoveredIdx >= 0 &&
|
||||||
|
hoveredDay &&
|
||||||
|
createPortal(
|
||||||
|
// Portalled to document.body (not rendered as a normal
|
||||||
|
// descendant here) so the card escapes `.calendar-week`'s
|
||||||
|
// and `.calendar-band`'s `overflow: hidden` — both clip
|
||||||
|
// deliberately (a band must never bleed into the next row),
|
||||||
|
// which would otherwise also invisibly clip this card the
|
||||||
|
// moment it pokes above its row. See BandDayHoverCard's doc
|
||||||
|
// comment for how it then positions itself from `anchorEl`.
|
||||||
|
<BandDayHoverCard
|
||||||
|
id={hoverCardId}
|
||||||
|
parentName={label}
|
||||||
|
date={hoveredInfo.date}
|
||||||
|
dayType={hoveredInfo.dayType}
|
||||||
|
isGap={hoveredInfo.isGap}
|
||||||
|
isDubbeldag={hoveredInfo.isDubbeldag}
|
||||||
|
isPinned={hoveredInfo.isPinned}
|
||||||
|
periodStart={segment.overallStart}
|
||||||
|
periodEnd={segment.overallEnd}
|
||||||
|
anchorEl={hoveredDay.anchorEl}
|
||||||
|
/>,
|
||||||
|
document.body,
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -226,4 +226,12 @@ export const calendar = {
|
|||||||
'calendar.wantsDubbeldagHint':
|
'calendar.wantsDubbeldagHint':
|
||||||
"Both parents are off the same day and each spend one of their own parental-benefit days (no vacation day is used) — only available in the child's first 15 months, capped at 60 dates per child, shared between you.",
|
"Both parents are off the same day and each spend one of their own parental-benefit days (no vacation day is used) — only available in the child's first 15 months, capped at 60 dates per child, shared between you.",
|
||||||
'calendar.wantsDubbeldagRequiresBothHint': 'Requires the period to apply to both parents.',
|
'calendar.wantsDubbeldagRequiresBothHint': 'Requires the period to apply to both parents.',
|
||||||
|
|
||||||
|
// Calendar band hover/focus card (see BandDayHoverCard) — one label per
|
||||||
|
// same-type run inside a band replaced the single band-wide label, so a
|
||||||
|
// per-day card fills in the detail (parent, date, type, flags, period
|
||||||
|
// range) that no longer fits in the run label itself.
|
||||||
|
'calendarBand.hover.gapDay': 'Weekend or public holiday — no leave day is drawn here',
|
||||||
|
'calendarBand.hover.dubbeldag': 'Double day',
|
||||||
|
'calendarBand.hover.period': '{start} – {end} · {count} {count|day|days}',
|
||||||
} as const
|
} as const
|
||||||
|
|||||||
@@ -227,4 +227,13 @@ export const calendar = {
|
|||||||
'calendar.wantsDubbeldagHint':
|
'calendar.wantsDubbeldagHint':
|
||||||
'Båda föräldrarna är lediga samma dag och tar var sin egen föräldrapenningdag (ingen semesterdag går åt) — gäller bara de första 15 månaderna och max 60 dagar per barn, delat mellan er.',
|
'Båda föräldrarna är lediga samma dag och tar var sin egen föräldrapenningdag (ingen semesterdag går åt) — gäller bara de första 15 månaderna och max 60 dagar per barn, delat mellan er.',
|
||||||
'calendar.wantsDubbeldagRequiresBothHint': 'Kräver att perioden gäller båda föräldrarna.',
|
'calendar.wantsDubbeldagRequiresBothHint': 'Kräver att perioden gäller båda föräldrarna.',
|
||||||
|
|
||||||
|
// Kort som visas vid hover/fokus på en dag i ett band (se
|
||||||
|
// BandDayHoverCard) — en etikett per sammanhängande typ i ett band
|
||||||
|
// ersatte den enda bandbreda etiketten, så det här kortet fyller i
|
||||||
|
// detaljen (förälder, datum, typ, flaggor, periodens intervall) som inte
|
||||||
|
// längre får plats i själva etiketten.
|
||||||
|
'calendarBand.hover.gapDay': 'Helg eller röd dag — ingen ledighetsdag ritas här',
|
||||||
|
'calendarBand.hover.dubbeldag': 'Dubbeldag',
|
||||||
|
'calendarBand.hover.period': '{start} – {end} · {count} {count|dag|dagar}',
|
||||||
} as const
|
} as const
|
||||||
@@ -404,9 +404,12 @@
|
|||||||
border-bottom-right-radius: var(--radius-band);
|
border-bottom-right-radius: var(--radius-band);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* The owner cap and lägsta's outline are drawn on an overlay above the
|
/* The owner cap is drawn on an overlay above the per-day ticks (which would
|
||||||
* per-day ticks (which would otherwise paint over an inset shadow on the
|
* otherwise paint over an inset shadow on the band itself) and below the
|
||||||
* band itself) and below the label. */
|
* label. Lägsta's outline used to be composed here too, band-wide from
|
||||||
|
* whichever type happened to be the band's first day — now it's a per-run
|
||||||
|
* overlay instead (`.calendar-band__run--lagsta` below), since a band can
|
||||||
|
* mix types day to day. */
|
||||||
.calendar-band::after {
|
.calendar-band::after {
|
||||||
content: '';
|
content: '';
|
||||||
position: absolute;
|
position: absolute;
|
||||||
@@ -416,14 +419,12 @@
|
|||||||
border-radius: inherit;
|
border-radius: inherit;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* The self/partner cap-mark and lägsta's outline are the shared
|
/* The self/partner cap-mark is the shared `.leave-swatch--cap-self`/
|
||||||
* `.leave-swatch--cap-self`/`--cap-partner`/`--lagsta` recipe from
|
* `--cap-partner` recipe from primitives.css, scoped to `::after` here —
|
||||||
* primitives.css, scoped to `::after` here — see the comment there and on
|
* see the comment there and on `.calendar-band::after` above for why.
|
||||||
* `.calendar-band::after` above for why. `getTypeKey`'s `--lagsta`/
|
* `CalendarWeek.tsx` adds the class alongside the band's own modifiers, so
|
||||||
* `leave-swatch--lagsta` and the owner's `--self`/`--partner`/
|
* the owner's cap composes onto the band without this file stating the
|
||||||
* `leave-swatch--cap-*` are added side by side in CalendarWeek.tsx, so
|
* box-shadow value itself. */
|
||||||
* lägsta's outline and the owner's cap keep composing even though this
|
|
||||||
* file no longer states the box-shadow values itself. */
|
|
||||||
|
|
||||||
.calendar-band__day--lagsta {
|
.calendar-band__day--lagsta {
|
||||||
background-image: repeating-linear-gradient(
|
background-image: repeating-linear-gradient(
|
||||||
@@ -453,11 +454,17 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* The label: 12px/500 in the type's ink (set inline on the band from
|
/* One label per same-type run inside the band (see `computeTypeRuns` in
|
||||||
* getInkForType), no shadow. */
|
* CalendarWeek.tsx), not one label for the whole band — a mixed-type band
|
||||||
|
* (vacation → sjukpenning mid-period) would otherwise show unlabelled text
|
||||||
|
* in the wrong ink over the later type's days. Each instance is positioned
|
||||||
|
* over its own run via inline `left`/`width` percentages and inked from
|
||||||
|
* that run's own type (inline `color`, from getInkForType), 12px/500, no
|
||||||
|
* shadow. */
|
||||||
.calendar-band__label {
|
.calendar-band__label {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
inset: 0;
|
top: 0;
|
||||||
|
bottom: 0;
|
||||||
z-index: 2;
|
z-index: 2;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -465,6 +472,24 @@
|
|||||||
font-size: var(--fs-xs);
|
font-size: var(--fs-xs);
|
||||||
color: inherit;
|
color: inherit;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* A per-run decoration overlay, sized/positioned like `.calendar-band__label`
|
||||||
|
* above but drawing no text — currently only the lägsta outline, which used
|
||||||
|
* to be band-wide (drawn from the first day's type) and wrongly outlined a
|
||||||
|
* mixed band across every day. Sits above the per-day ticks, below the
|
||||||
|
* label. */
|
||||||
|
.calendar-band__run {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
bottom: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.calendar-band__run--lagsta {
|
||||||
|
z-index: 1;
|
||||||
|
box-shadow: inset 0 0 0 1px var(--c-leave-lagsta-outline);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* The per-day funding-type strip: a period is ONE band, but each day still
|
/* The per-day funding-type strip: a period is ONE band, but each day still
|
||||||
|
|||||||
@@ -463,8 +463,10 @@
|
|||||||
/* Modal shell
|
/* Modal shell
|
||||||
* ------------------------------------------------------------------------
|
* ------------------------------------------------------------------------
|
||||||
* Shared by PeriodPopover, PlannedDayModal, ParentBalanceForm and
|
* Shared by PeriodPopover, PlannedDayModal, ParentBalanceForm and
|
||||||
* QRCodeModal. The one floating layer in the app, so the one place
|
* QRCodeModal — the app's main floating layer, so the main place
|
||||||
* --shadow-float is used. Header and footer have no rules of their own;
|
* --shadow-float is used (the calendar band's day hover card, a much
|
||||||
|
* smaller non-interactive tooltip, is the other — see
|
||||||
|
* band-day-hover-card.css). Header and footer have no rules of their own;
|
||||||
* the footer is transparent — spacing does the separating. */
|
* the footer is transparent — spacing does the separating. */
|
||||||
.modal__overlay {
|
.modal__overlay {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
@@ -596,15 +598,16 @@
|
|||||||
* legend; consumers keep only their own size/position rule and add one of
|
* legend; consumers keep only their own size/position rule and add one of
|
||||||
* these classes alongside their existing BEM modifier.
|
* these classes alongside their existing BEM modifier.
|
||||||
*
|
*
|
||||||
* `--lagsta`/`--cap-self`/`--cap-partner` are defined twice: once for a
|
* `--cap-self`/`--cap-partner` are defined twice: once for a plain element
|
||||||
* plain element (the year-row swatch, the calendar-help sample — nothing
|
* (the year-row swatch, the calendar-help sample — nothing paints over
|
||||||
* paints over them) and once scoped to `::after` (the calendar band, whose
|
* them) and once scoped to `::after` (the calendar band, whose per-day
|
||||||
* per-day ticks sit on top of the band's own background and would hide an
|
* ticks sit on top of the band's own background and would hide an inset
|
||||||
* inset shadow drawn directly on it — see `.calendar-band::after` in
|
* shadow drawn directly on it — see `.calendar-band::after` in
|
||||||
* calendar.css). The combined lagsta+cap-self / lagsta+cap-partner
|
* calendar.css). `--lagsta` only needs the plain-element form: the band no
|
||||||
* `::after` rules exist because `box-shadow` doesn't merge across two
|
* longer wears it at all (a mixed-type band's lägsta days get their own
|
||||||
* separate rules — the more specific combined selector is what lets a
|
* per-run outline overlay instead — `.calendar-band__run--lagsta` in
|
||||||
* lägsta band still show its owner's cap. */
|
* calendar.css — since the type varies day to day and the old band-wide
|
||||||
|
* `--lagsta` class only ever reflected the first day). */
|
||||||
.leave-swatch--vacation {
|
.leave-swatch--vacation {
|
||||||
background-color: var(--c-leave-vacation);
|
background-color: var(--c-leave-vacation);
|
||||||
}
|
}
|
||||||
@@ -619,10 +622,6 @@
|
|||||||
box-shadow: inset 0 0 0 1px var(--c-leave-lagsta-outline);
|
box-shadow: inset 0 0 0 1px var(--c-leave-lagsta-outline);
|
||||||
}
|
}
|
||||||
|
|
||||||
.leave-swatch--lagsta::after {
|
|
||||||
box-shadow: inset 0 0 0 1px var(--c-leave-lagsta-outline);
|
|
||||||
}
|
|
||||||
|
|
||||||
.leave-swatch--cap-self {
|
.leave-swatch--cap-self {
|
||||||
box-shadow: inset 2px 0 0 var(--c-band-cap-self);
|
box-shadow: inset 2px 0 0 var(--c-band-cap-self);
|
||||||
}
|
}
|
||||||
@@ -639,14 +638,6 @@
|
|||||||
box-shadow: inset 2px 0 0 var(--c-band-cap-partner);
|
box-shadow: inset 2px 0 0 var(--c-band-cap-partner);
|
||||||
}
|
}
|
||||||
|
|
||||||
.leave-swatch--lagsta.leave-swatch--cap-self::after {
|
|
||||||
box-shadow: inset 0 0 0 1px var(--c-leave-lagsta-outline), inset 2px 0 0 var(--c-band-cap-self);
|
|
||||||
}
|
|
||||||
|
|
||||||
.leave-swatch--lagsta.leave-swatch--cap-partner::after {
|
|
||||||
box-shadow: inset 0 0 0 1px var(--c-leave-lagsta-outline), inset 2px 0 0 var(--c-band-cap-partner);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Layout helpers
|
/* Layout helpers
|
||||||
* ------------------------------------------------------------------------ */
|
* ------------------------------------------------------------------------ */
|
||||||
.stack {
|
.stack {
|
||||||
|
|||||||
@@ -32,6 +32,24 @@ export function formatDateForDisplay(date: string | Date, locale: Locale = 'sv')
|
|||||||
return formatter.format(d)
|
return formatter.format(d)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format a date as "weekday, day month" for display (e.g. "fredag 5 juni" in
|
||||||
|
* Swedish, "Friday, June 5" in English) — used by the calendar band hover
|
||||||
|
* card, which needs the weekday alongside the date and has no calendar cell
|
||||||
|
* of its own to supply it from context. Unlike `getDayName`/`getMonthName`
|
||||||
|
* (Swedish-only), this takes the active locale like `formatDateForDisplay`.
|
||||||
|
*/
|
||||||
|
export function formatWeekdayAndDate(date: string | Date, locale: Locale = 'sv'): string {
|
||||||
|
const d = typeof date === 'string' ? new Date(date + 'T00:00:00Z') : date
|
||||||
|
if (!isValidDate(d)) return ''
|
||||||
|
const formatter = new Intl.DateTimeFormat(DISPLAY_INTL_LOCALE[locale], {
|
||||||
|
weekday: 'long',
|
||||||
|
day: 'numeric',
|
||||||
|
month: 'long',
|
||||||
|
})
|
||||||
|
return formatter.format(d)
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Format a date for HTML input value (e.g. "2026-06-01").
|
* Format a date for HTML input value (e.g. "2026-06-01").
|
||||||
* Accepts ISO string or Date object.
|
* Accepts ISO string or Date object.
|
||||||
|
|||||||
Reference in New Issue
Block a user