Redraw the calendar as an almanac: gridless cells, typed bands
Day cells lose their borders and fills; red days, klämdagar and Saturdays are told apart by the numeral's ink with a 12px R/K mark, today is a filled circle, overrides and exclusions mark the numeral. Bands carry their funding type by fill, ink and hatching and their owner by a left cap. The preset rail becomes a chip row, the legend a help popover, the period popover's three delete buttons fold behind one "Ta bort…", and solo users pick a type directly in the day modal. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XBRLomdLK9jF7wU7a8AA6H
This commit is contained in:
@@ -10,9 +10,10 @@
|
||||
* default it now creates a fixed, one-off `LeaveWish` and lets the planner
|
||||
* fund it (a `calendar-band-wish:` band, not `adhoc:`) — see
|
||||
* `Calendar.tsx`'s `handleSaveWish`. The old "write exact ad-hoc
|
||||
* `planned_leave_day` rows" behaviour still exists, behind the modal's
|
||||
* "Advanced" disclosure (`handleSavePinnedDay`) — the first test below
|
||||
* opens it explicitly to keep exercising that path.
|
||||
* `planned_leave_day` rows" behaviour still exists (`handleSavePinnedDay`):
|
||||
* a solo household reaches it by picking a concrete type in the modal's
|
||||
* type select (a couple through the "Advanced" disclosure) — the first
|
||||
* test below does so explicitly to keep exercising that path.
|
||||
*
|
||||
* The drag deliberately crosses a month boundary (the last two days of one
|
||||
* month into the first day of the next) — a regression guard for the
|
||||
@@ -57,7 +58,7 @@ function targetRange(): { month: Date; dates: [string, string, string] } {
|
||||
return { month, dates }
|
||||
}
|
||||
|
||||
test('dragging a range and pinning it through Advanced plans every day exactly, and deleting it removes the days for good', async ({
|
||||
test('dragging a range and pinning an exact type plans every day exactly, and deleting it removes the days for good', async ({
|
||||
page,
|
||||
}) => {
|
||||
await registerAndReachOnboarding(page, uniqueEmail('drag-delete'), 'Robin Testsson')
|
||||
@@ -79,10 +80,12 @@ test('dragging a range and pinning it through Advanced plans every day exactly,
|
||||
// A multi-day drag opens the day popover pre-selected on every dragged
|
||||
// date — it does not create a period/wish by itself.
|
||||
await expect(page.getByTestId('modal-title')).toHaveText('Planera 3 dagar')
|
||||
// The default save path would create a fixed wish instead (see the next
|
||||
// test) — opening "Advanced" keeps this test on the exact-pinned-days
|
||||
// path todo.md #10/#11 kept as an escape hatch.
|
||||
await page.getByTestId('advanced-toggle').click()
|
||||
// The default save path ("let the planner choose") would create a fixed
|
||||
// wish instead (see the next test) — picking a concrete type keeps this
|
||||
// test on the exact-pinned-days path todo.md #10/#11 kept as an escape
|
||||
// hatch. A solo account has no "Advanced" toggle: the select is right
|
||||
// there.
|
||||
await page.getByTestId('day-type-select').selectOption('vacation')
|
||||
await page.getByTestId('save-button').click()
|
||||
await expect(page.getByTestId('planned-day-modal__content')).not.toBeVisible()
|
||||
|
||||
@@ -112,9 +115,11 @@ test('dragging a range and pinning it through Advanced plans every day exactly,
|
||||
await expect(page.getByTestId('period-adhoc-notice')).toBeVisible()
|
||||
await expect(page.getByTestId('period-popover__content')).toContainText(`${firstDate} – ${lastDate}`)
|
||||
|
||||
// todo.md #9 — an ad-hoc band has no recurring plan, so "every year" is
|
||||
// disabled; "this day"/"this period" are the only meaningful choices, and
|
||||
// "this period" removes the whole ad-hoc range (today's only behaviour).
|
||||
// todo.md #9 — the three delete scopes sit behind "Ta bort…". An ad-hoc
|
||||
// band has no recurring plan, so "every year" is disabled; "this day"/
|
||||
// "this period" are the only meaningful choices, and "this period"
|
||||
// removes the whole ad-hoc range (today's only behaviour).
|
||||
await page.getByTestId('period-delete-menu-button').click()
|
||||
await expect(page.getByTestId('period-delete-all-years-button')).toBeDisabled()
|
||||
await page.getByTestId('period-delete-year-button').click()
|
||||
await expect(page.getByTestId('period-delete-confirm')).toBeVisible()
|
||||
@@ -183,6 +188,7 @@ test('a drag saved without opening Advanced creates a planner-funded wish, and t
|
||||
// thing, same as "every year".
|
||||
await wishBand.first().click()
|
||||
await expect(page.getByTestId('period-popover__content')).toBeVisible()
|
||||
await page.getByTestId('period-delete-menu-button').click()
|
||||
await expect(page.getByTestId('period-delete-day-button')).toBeEnabled()
|
||||
|
||||
await page.getByTestId('period-delete-year-button').click()
|
||||
@@ -224,6 +230,7 @@ test('removing a single day out of a planner-placed period keeps it removed acro
|
||||
// `clickedDate`). Either way the popover's "this day" removes exactly one.
|
||||
await wishBand.first().click()
|
||||
await expect(page.getByTestId('period-popover__content')).toBeVisible()
|
||||
await page.getByTestId('period-delete-menu-button').click()
|
||||
await page.getByTestId('period-delete-day-button').click()
|
||||
await expect(page.getByTestId('period-delete-confirm')).toBeVisible()
|
||||
await page.getByTestId('period-delete-confirm-button').click()
|
||||
|
||||
@@ -24,40 +24,52 @@ import { calendarDayCell, goToCalendarMonth } from '../support/calendarNav'
|
||||
* element actually painted with it — i.e. exactly what `toHaveCSS` compares
|
||||
* against. Reading the token instead of a hardcoded literal means a future
|
||||
* restyle that moves the palette can't silently desync this spec from
|
||||
* calendar.css: whatever `--c-danger-weak`/`--c-weekend-bg` resolve to at
|
||||
* test time is what's asserted.
|
||||
* calendar.css: whatever `--c-danger`/`--c-text-faint` resolve to at test
|
||||
* time is what's asserted.
|
||||
*/
|
||||
async function resolveCssVar(page: Page, varName: string): Promise<string> {
|
||||
return page.evaluate((name) => {
|
||||
const probe = document.createElement('div')
|
||||
probe.style.backgroundColor = `var(${name})`
|
||||
probe.style.color = `var(${name})`
|
||||
document.body.appendChild(probe)
|
||||
const resolved = getComputedStyle(probe).backgroundColor
|
||||
const resolved = getComputedStyle(probe).color
|
||||
probe.remove()
|
||||
return resolved
|
||||
}, varName)
|
||||
}
|
||||
|
||||
/** The day's numeral — the element that carries the red/Saturday colour in
|
||||
* the Almanacka design (cells themselves have no fill). */
|
||||
function dayNumeral(cell: ReturnType<typeof calendarDayCell>) {
|
||||
return cell.locator('.calendar-day__number')
|
||||
}
|
||||
|
||||
test.describe('red days, klämdagar and the combined-holiday-name trap', () => {
|
||||
test('Easter Sunday (Påskdagen) renders as a named red day', async ({ page }) => {
|
||||
const easter = nextEasterOnOrAfter(new Date())
|
||||
await goToCalendarMonth(page, easter)
|
||||
const redDayBg = await resolveCssVar(page, '--c-danger-weak') // .calendar-day--red-day
|
||||
const redInk = await resolveCssVar(page, '--c-danger') // .calendar-day--red-day .calendar-day__number
|
||||
const redFill = await resolveCssVar(page, '--c-danger-weak')
|
||||
|
||||
const cell = calendarDayCell(page, toIsoDate(easter))
|
||||
await expect(cell).toHaveClass(/calendar-day--red-day/)
|
||||
await expect(cell).toHaveCSS('background-color', redDayBg)
|
||||
// The numeral carries the colour; the cell itself has no red fill.
|
||||
await expect(dayNumeral(cell)).toHaveCSS('color', redInk)
|
||||
await expect(cell).not.toHaveCSS('background-color', redFill)
|
||||
await expect(cell.getByTestId('calendar-day-type-badge')).toHaveText('R')
|
||||
await expect(cell.getByTestId('calendar-day-holiday-name')).toHaveText('Påskdagen')
|
||||
})
|
||||
|
||||
test('Whit Sunday (Pingstdagen) renders as a named red day', async ({ page }) => {
|
||||
const whitsun = whitSunday(nextEasterOnOrAfter(new Date()))
|
||||
await goToCalendarMonth(page, whitsun)
|
||||
const redDayBg = await resolveCssVar(page, '--c-danger-weak') // .calendar-day--red-day
|
||||
const redInk = await resolveCssVar(page, '--c-danger') // .calendar-day--red-day .calendar-day__number
|
||||
const redFill = await resolveCssVar(page, '--c-danger-weak')
|
||||
|
||||
const cell = calendarDayCell(page, toIsoDate(whitsun))
|
||||
await expect(cell).toHaveClass(/calendar-day--red-day/)
|
||||
await expect(cell).toHaveCSS('background-color', redDayBg)
|
||||
await expect(dayNumeral(cell)).toHaveCSS('color', redInk)
|
||||
await expect(cell).not.toHaveCSS('background-color', redFill)
|
||||
await expect(cell.getByTestId('calendar-day-holiday-name')).toHaveText('Pingstdagen')
|
||||
})
|
||||
|
||||
@@ -77,18 +89,24 @@ test.describe('red days, klämdagar and the combined-holiday-name trap', () => {
|
||||
// that first Mon–Sun week row (see Calendar.tsx's `startOffset`) — so
|
||||
// this never depends on the two dates sharing a calendar month.
|
||||
await goToCalendarMonth(page, plainSunday)
|
||||
const redDayBg = await resolveCssVar(page, '--c-danger-weak') // .calendar-day--red-day
|
||||
const weekendBg = await resolveCssVar(page, '--c-weekend-bg') // .calendar-day--weekend
|
||||
const redInk = await resolveCssVar(page, '--c-danger') // .calendar-day--red-day .calendar-day__number
|
||||
const redFill = await resolveCssVar(page, '--c-danger-weak')
|
||||
const saturdayInk = await resolveCssVar(page, '--c-text-faint') // .calendar-day--weekend .calendar-day__number
|
||||
|
||||
const sundayCell = calendarDayCell(page, toIsoDate(plainSunday))
|
||||
await expect(sundayCell).toHaveClass(/calendar-day--red-day/)
|
||||
await expect(sundayCell).not.toHaveClass(/calendar-day--weekend/)
|
||||
await expect(sundayCell).toHaveCSS('background-color', redDayBg)
|
||||
await expect(dayNumeral(sundayCell)).toHaveCSS('color', redInk)
|
||||
await expect(sundayCell).not.toHaveCSS('background-color', redFill)
|
||||
await expect(sundayCell.getByTestId('calendar-day-type-badge')).toHaveText('R')
|
||||
await expect(sundayCell.getByTestId('calendar-day-holiday-name')).toHaveCount(0)
|
||||
|
||||
const saturdayCell = calendarDayCell(page, toIsoDate(plainSaturday))
|
||||
await expect(saturdayCell).toHaveClass(/calendar-day--weekend/)
|
||||
await expect(saturdayCell).not.toHaveClass(/calendar-day--red-day/)
|
||||
await expect(saturdayCell).toHaveCSS('background-color', weekendBg)
|
||||
// A plain Saturday is a faint numeral, not a red one, and no fill.
|
||||
await expect(dayNumeral(saturdayCell)).toHaveCSS('color', saturdayInk)
|
||||
await expect(dayNumeral(saturdayCell)).not.toHaveCSS('color', redInk)
|
||||
await expect(saturdayCell).not.toHaveCSS('background-color', redFill)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { render, screen, fireEvent } from '@testing-library/react'
|
||||
import { CalendarHelpPopover } from './CalendarHelpPopover'
|
||||
|
||||
describe('CalendarHelpPopover', () => {
|
||||
it('starts closed, with the button collapsed', () => {
|
||||
render(<CalendarHelpPopover />)
|
||||
expect(screen.getByTestId('calendar-help-button')).toHaveAttribute('aria-expanded', 'false')
|
||||
expect(screen.queryByTestId('calendar-help-panel')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('opens on click and lists every day mark and band style', () => {
|
||||
render(<CalendarHelpPopover />)
|
||||
fireEvent.click(screen.getByTestId('calendar-help-button'))
|
||||
|
||||
expect(screen.getByTestId('calendar-help-button')).toHaveAttribute('aria-expanded', 'true')
|
||||
const panel = screen.getByTestId('calendar-help-panel')
|
||||
expect(panel).toHaveTextContent('Röd dag')
|
||||
expect(panel).toHaveTextContent('Klämdag')
|
||||
expect(panel).toHaveTextContent('Lördag')
|
||||
expect(panel).toHaveTextContent('Idag')
|
||||
expect(panel).toHaveTextContent('Undantag')
|
||||
expect(panel).toHaveTextContent('Borttagen dag')
|
||||
expect(panel).toHaveTextContent('Semester')
|
||||
expect(panel).toHaveTextContent('Sjukpenningnivå')
|
||||
expect(panel).toHaveTextContent('Lägstanivå')
|
||||
expect(panel).toHaveTextContent('Dubbeldag')
|
||||
// The samples are drawn with the grid's own marks.
|
||||
expect(panel.querySelector('.calendar-help__day--red')).toHaveTextContent('R')
|
||||
expect(panel.querySelector('.calendar-help__day--klamdag')).toHaveTextContent('K')
|
||||
expect(panel.querySelector('.calendar-help__day--override [data-icon="briefcase"]')).toBeInTheDocument()
|
||||
expect(panel.querySelector('.calendar-help__band--dubbeldag')).toHaveTextContent('2×')
|
||||
})
|
||||
|
||||
it('closes again on a second click', () => {
|
||||
render(<CalendarHelpPopover />)
|
||||
fireEvent.click(screen.getByTestId('calendar-help-button'))
|
||||
fireEvent.click(screen.getByTestId('calendar-help-button'))
|
||||
expect(screen.queryByTestId('calendar-help-panel')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('closes on Escape', () => {
|
||||
render(<CalendarHelpPopover />)
|
||||
fireEvent.click(screen.getByTestId('calendar-help-button'))
|
||||
fireEvent.keyDown(document, { key: 'Escape' })
|
||||
expect(screen.queryByTestId('calendar-help-panel')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('ignores other keys', () => {
|
||||
render(<CalendarHelpPopover />)
|
||||
fireEvent.click(screen.getByTestId('calendar-help-button'))
|
||||
fireEvent.keyDown(document, { key: 'Enter' })
|
||||
expect(screen.getByTestId('calendar-help-panel')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('closes on a pointer down outside, but not on one inside the panel', () => {
|
||||
render(
|
||||
<div>
|
||||
<CalendarHelpPopover />
|
||||
<button type="button" data-testid="outside">
|
||||
outside
|
||||
</button>
|
||||
</div>,
|
||||
)
|
||||
fireEvent.click(screen.getByTestId('calendar-help-button'))
|
||||
fireEvent.pointerDown(screen.getByTestId('calendar-help-panel'))
|
||||
expect(screen.getByTestId('calendar-help-panel')).toBeInTheDocument()
|
||||
|
||||
fireEvent.pointerDown(screen.getByTestId('outside'))
|
||||
expect(screen.queryByTestId('calendar-help-panel')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('does not listen for outside clicks while closed', () => {
|
||||
render(
|
||||
<div>
|
||||
<CalendarHelpPopover />
|
||||
<button type="button" data-testid="outside">
|
||||
outside
|
||||
</button>
|
||||
</div>,
|
||||
)
|
||||
fireEvent.pointerDown(screen.getByTestId('outside'))
|
||||
fireEvent.click(screen.getByTestId('calendar-help-button'))
|
||||
expect(screen.getByTestId('calendar-help-panel')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,104 @@
|
||||
import { ReactNode, useEffect, useId, useRef, useState } from 'react'
|
||||
import { useTranslation, type TranslationKey } from '@/i18n'
|
||||
import { Icon } from '@/components/common/Icon'
|
||||
import './calendar-help-popover.css'
|
||||
|
||||
/** The day marks, each drawn the same way the grid draws it. */
|
||||
const DAY_MARKS: ReadonlyArray<{ key: string; className: string; labelKey: TranslationKey; sample: string }> = [
|
||||
{ key: 'red', className: 'calendar-help__day calendar-help__day--red', labelKey: 'calendar.helpRedDay', sample: '6' },
|
||||
{ key: 'klamdag', className: 'calendar-help__day calendar-help__day--klamdag', labelKey: 'calendar.helpKlamdag', sample: '5' },
|
||||
{ key: 'saturday', className: 'calendar-help__day calendar-help__day--saturday', labelKey: 'calendar.helpSaturday', sample: '12' },
|
||||
{ key: 'today', className: 'calendar-help__day calendar-help__day--today', labelKey: 'calendar.helpToday', sample: '19' },
|
||||
{ key: 'override', className: 'calendar-help__day calendar-help__day--override', labelKey: 'calendar.helpOverride', sample: '24' },
|
||||
{ key: 'excluded', className: 'calendar-help__day calendar-help__day--excluded', labelKey: 'calendar.helpExcluded', sample: '17' },
|
||||
]
|
||||
|
||||
/** The band styles: type by fill/ink/outline, owner by the left cap. */
|
||||
const BAND_MARKS: ReadonlyArray<{ key: string; className: string; labelKey: TranslationKey; text?: string }> = [
|
||||
{ key: 'vacation', className: 'calendar-help__band calendar-help__band--vacation', labelKey: 'calendar.helpVacation' },
|
||||
{ key: 'sjukpenning', className: 'calendar-help__band calendar-help__band--sjukpenning', labelKey: 'calendar.helpSjukpenning' },
|
||||
{ key: 'lagsta', className: 'calendar-help__band calendar-help__band--lagsta', labelKey: 'calendar.helpLagsta' },
|
||||
{ key: 'self', className: 'calendar-help__band calendar-help__band--self', labelKey: 'calendar.helpSelf' },
|
||||
{ key: 'partner', className: 'calendar-help__band calendar-help__band--partner', labelKey: 'calendar.helpPartner' },
|
||||
{ key: 'dubbeldag', className: 'calendar-help__band calendar-help__band--dubbeldag', labelKey: 'calendar.helpDubbeldag', text: '2×' },
|
||||
{ key: 'pinned', className: 'calendar-help__band calendar-help__band--pinned', labelKey: 'calendar.helpPinned' },
|
||||
]
|
||||
|
||||
/**
|
||||
* The "?" beside the month title: a small floating panel that explains
|
||||
* every mark the grid uses (the R/K superscripts, the today circle, the
|
||||
* band fills and caps). It replaces the seven-item legend that used to sit
|
||||
* under the calendar — the marks are self-explanatory most of the time, so
|
||||
* the explanation is a click away rather than always on screen.
|
||||
*
|
||||
* Closes on Escape, on a click outside, or on the button again.
|
||||
*/
|
||||
export function CalendarHelpPopover(): ReactNode {
|
||||
const { t } = useTranslation()
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
const rootRef = useRef<HTMLDivElement>(null)
|
||||
const panelId = useId()
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) return
|
||||
const handlePointerDown = (event: PointerEvent) => {
|
||||
if (rootRef.current && event.target instanceof Node && !rootRef.current.contains(event.target)) {
|
||||
setIsOpen(false)
|
||||
}
|
||||
}
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape') setIsOpen(false)
|
||||
}
|
||||
document.addEventListener('pointerdown', handlePointerDown)
|
||||
document.addEventListener('keydown', handleKeyDown)
|
||||
return () => {
|
||||
document.removeEventListener('pointerdown', handlePointerDown)
|
||||
document.removeEventListener('keydown', handleKeyDown)
|
||||
}
|
||||
}, [isOpen])
|
||||
|
||||
return (
|
||||
<div className="calendar-help" ref={rootRef}>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn--icon calendar-help__button"
|
||||
onClick={() => setIsOpen((open) => !open)}
|
||||
aria-label={t('calendar.helpAria')}
|
||||
aria-expanded={isOpen}
|
||||
aria-controls={panelId}
|
||||
data-testid="calendar-help-button"
|
||||
>
|
||||
<Icon name="help" size={20} />
|
||||
</button>
|
||||
{isOpen && (
|
||||
<div className="calendar-help__panel" id={panelId} role="dialog" aria-label={t('calendar.helpTitle')} data-testid="calendar-help-panel">
|
||||
<h3 className="calendar-help__title">{t('calendar.helpTitle')}</h3>
|
||||
<ul className="calendar-help__list">
|
||||
{DAY_MARKS.map((mark) => (
|
||||
<li key={mark.key} className="calendar-help__item">
|
||||
<span className={mark.className} aria-hidden="true">
|
||||
<span className="calendar-help__numeral">{mark.sample}</span>
|
||||
{mark.key === 'red' && <span className="calendar-help__sup">R</span>}
|
||||
{mark.key === 'klamdag' && <span className="calendar-help__sup">K</span>}
|
||||
{mark.key === 'override' && <Icon name="briefcase" size={16} />}
|
||||
</span>
|
||||
<span className="calendar-help__label">{t(mark.labelKey)}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<h3 className="calendar-help__title">{t('calendar.helpBandsTitle')}</h3>
|
||||
<ul className="calendar-help__list">
|
||||
{BAND_MARKS.map((mark) => (
|
||||
<li key={mark.key} className="calendar-help__item">
|
||||
<span className={mark.className} aria-hidden="true">
|
||||
{mark.text}
|
||||
</span>
|
||||
<span className="calendar-help__label">{t(mark.labelKey)}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -23,11 +23,47 @@ describe('PeriodPopover', () => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
/** The three delete scopes sit behind "Ta bort…" — press it first. */
|
||||
function openDeleteMenu() {
|
||||
fireEvent.click(screen.getByTestId('period-delete-menu-button'))
|
||||
}
|
||||
|
||||
it('returns null when not open', () => {
|
||||
const { container } = render(<PeriodPopover {...defaultProps} isOpen={false} />)
|
||||
expect(container.firstChild).toBeNull()
|
||||
})
|
||||
|
||||
describe('the delete menu', () => {
|
||||
it('keeps the three delete scopes hidden until "Ta bort…" is pressed', () => {
|
||||
render(<PeriodPopover {...defaultProps} />)
|
||||
expect(screen.queryByTestId('period-delete-group')).not.toBeInTheDocument()
|
||||
expect(screen.getByTestId('period-delete-menu-button')).toHaveTextContent('Ta bort…')
|
||||
expect(screen.getByTestId('period-delete-menu-button')).toHaveAttribute('aria-expanded', 'false')
|
||||
|
||||
openDeleteMenu()
|
||||
expect(screen.getByTestId('period-delete-menu-button')).toHaveAttribute('aria-expanded', 'true')
|
||||
expect(screen.getByTestId('period-delete-group')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('period-delete-day-button')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('period-delete-year-button')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('period-delete-all-years-button')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('folds the scopes away again on a second press', () => {
|
||||
render(<PeriodPopover {...defaultProps} />)
|
||||
openDeleteMenu()
|
||||
openDeleteMenu()
|
||||
expect(screen.queryByTestId('period-delete-group')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('starts folded again each time the popover reopens', () => {
|
||||
const { rerender } = render(<PeriodPopover {...defaultProps} />)
|
||||
openDeleteMenu()
|
||||
rerender(<PeriodPopover {...defaultProps} isOpen={false} />)
|
||||
rerender(<PeriodPopover {...defaultProps} isOpen={true} />)
|
||||
expect(screen.queryByTestId('period-delete-group')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('ad-hoc band (no wish)', () => {
|
||||
it('shows the ad-hoc notice and date range instead of editable fields', () => {
|
||||
render(<PeriodPopover {...defaultProps} />)
|
||||
@@ -43,6 +79,7 @@ describe('PeriodPopover', () => {
|
||||
|
||||
it('shows the ad-hoc "this period" label and disables "every year" (nothing recurring to remove)', () => {
|
||||
render(<PeriodPopover {...defaultProps} />)
|
||||
openDeleteMenu()
|
||||
expect(screen.getByTestId('period-delete-year-button')).toHaveTextContent('Ta bort dessa dagar')
|
||||
expect(screen.getByTestId('period-delete-day-button')).not.toBeDisabled()
|
||||
expect(screen.getByTestId('period-delete-all-years-button')).toBeDisabled()
|
||||
@@ -54,6 +91,7 @@ describe('PeriodPopover', () => {
|
||||
|
||||
it('shows an inline confirmation instead of deleting immediately', () => {
|
||||
render(<PeriodPopover {...defaultProps} />)
|
||||
openDeleteMenu()
|
||||
fireEvent.click(screen.getByTestId('period-delete-year-button'))
|
||||
expect(mockOnDelete).not.toHaveBeenCalled()
|
||||
expect(screen.getByTestId('period-delete-confirm')).toBeInTheDocument()
|
||||
@@ -62,6 +100,7 @@ describe('PeriodPopover', () => {
|
||||
it('calls onDelete with the "year" scope after confirming inline', async () => {
|
||||
mockOnDelete.mockResolvedValue(undefined)
|
||||
render(<PeriodPopover {...defaultProps} />)
|
||||
openDeleteMenu()
|
||||
fireEvent.click(screen.getByTestId('period-delete-year-button'))
|
||||
fireEvent.click(screen.getByTestId('period-delete-confirm-button'))
|
||||
await waitFor(() => expect(mockOnDelete).toHaveBeenCalledWith('year'))
|
||||
@@ -70,6 +109,7 @@ describe('PeriodPopover', () => {
|
||||
it('calls onDelete with the "day" scope for the day button', async () => {
|
||||
mockOnDelete.mockResolvedValue(undefined)
|
||||
render(<PeriodPopover {...defaultProps} />)
|
||||
openDeleteMenu()
|
||||
fireEvent.click(screen.getByTestId('period-delete-day-button'))
|
||||
fireEvent.click(screen.getByTestId('period-delete-confirm-button'))
|
||||
await waitFor(() => expect(mockOnDelete).toHaveBeenCalledWith('day'))
|
||||
@@ -77,6 +117,7 @@ describe('PeriodPopover', () => {
|
||||
|
||||
it('does not call onDelete when the inline confirmation is cancelled', () => {
|
||||
render(<PeriodPopover {...defaultProps} />)
|
||||
openDeleteMenu()
|
||||
fireEvent.click(screen.getByTestId('period-delete-year-button'))
|
||||
fireEvent.click(screen.getByTestId('period-delete-cancel-button'))
|
||||
expect(mockOnDelete).not.toHaveBeenCalled()
|
||||
@@ -394,6 +435,7 @@ describe('PeriodPopover', () => {
|
||||
// the option is live — see Calendar.tsx's `excludeDay`.
|
||||
it('enables "this day" for a wish-linked band and explains what it does', () => {
|
||||
render(<PeriodPopover {...defaultProps} wish={wish} />)
|
||||
openDeleteMenu()
|
||||
expect(screen.getByTestId('period-delete-day-button')).not.toBeDisabled()
|
||||
expect(screen.getByTestId('period-delete-day-button')).toHaveAttribute(
|
||||
'title',
|
||||
@@ -406,6 +448,7 @@ describe('PeriodPopover', () => {
|
||||
it('calls onDelete with the "day" scope for a wish-linked band after confirmation', async () => {
|
||||
mockOnDelete.mockResolvedValue(undefined)
|
||||
render(<PeriodPopover {...defaultProps} wish={wish} />)
|
||||
openDeleteMenu()
|
||||
fireEvent.click(screen.getByTestId('period-delete-day-button'))
|
||||
fireEvent.click(screen.getByTestId('period-delete-confirm-button'))
|
||||
await waitFor(() => expect(mockOnDelete).toHaveBeenCalledWith('day'))
|
||||
@@ -420,6 +463,7 @@ describe('PeriodPopover', () => {
|
||||
removeDayParentName="Alex"
|
||||
/>,
|
||||
)
|
||||
openDeleteMenu()
|
||||
fireEvent.click(screen.getByTestId('period-delete-day-button'))
|
||||
expect(screen.getByTestId('period-delete-confirm')).toHaveTextContent(
|
||||
'Ta bort 17 juni 2028 för Alex?',
|
||||
@@ -428,6 +472,7 @@ describe('PeriodPopover', () => {
|
||||
|
||||
it('falls back to the generic confirmation when the parent is unknown', () => {
|
||||
render(<PeriodPopover {...defaultProps} wish={wish} removeDayLabel="17 juni 2028" />)
|
||||
openDeleteMenu()
|
||||
fireEvent.click(screen.getByTestId('period-delete-day-button'))
|
||||
expect(screen.getByTestId('period-delete-confirm')).toHaveTextContent(
|
||||
'Är du säker på att du vill ta bort den här dagen?',
|
||||
@@ -437,6 +482,7 @@ describe('PeriodPopover', () => {
|
||||
it('displays a generic local error when onDelete rejects with a non-Error value', async () => {
|
||||
mockOnDelete.mockRejectedValue('boom')
|
||||
render(<PeriodPopover {...defaultProps} wish={wish} />)
|
||||
openDeleteMenu()
|
||||
fireEvent.click(screen.getByTestId('period-delete-year-button'))
|
||||
fireEvent.click(screen.getByTestId('period-delete-confirm-button'))
|
||||
await waitFor(() => {
|
||||
@@ -447,6 +493,7 @@ describe('PeriodPopover', () => {
|
||||
it('calls onDelete with the "allYears" scope for a wish-linked band after confirmation', async () => {
|
||||
mockOnDelete.mockResolvedValue(undefined)
|
||||
render(<PeriodPopover {...defaultProps} wish={wish} />)
|
||||
openDeleteMenu()
|
||||
fireEvent.click(screen.getByTestId('period-delete-all-years-button'))
|
||||
fireEvent.click(screen.getByTestId('period-delete-confirm-button'))
|
||||
await waitFor(() => expect(mockOnDelete).toHaveBeenCalledWith('allYears'))
|
||||
@@ -455,6 +502,7 @@ describe('PeriodPopover', () => {
|
||||
it('calls onDelete with the "year" scope for a wish-linked band', async () => {
|
||||
mockOnDelete.mockResolvedValue(undefined)
|
||||
render(<PeriodPopover {...defaultProps} wish={wish} />)
|
||||
openDeleteMenu()
|
||||
fireEvent.click(screen.getByTestId('period-delete-year-button'))
|
||||
fireEvent.click(screen.getByTestId('period-delete-confirm-button'))
|
||||
await waitFor(() => expect(mockOnDelete).toHaveBeenCalledWith('year'))
|
||||
@@ -462,12 +510,14 @@ describe('PeriodPopover', () => {
|
||||
|
||||
it('shows the wish-linked "this period" and "every year" labels', () => {
|
||||
render(<PeriodPopover {...defaultProps} wish={wish} />)
|
||||
openDeleteMenu()
|
||||
expect(screen.getByTestId('period-delete-year-button')).toHaveTextContent('Den här perioden')
|
||||
expect(screen.getByTestId('period-delete-all-years-button')).toHaveTextContent('Den här perioden, varje år')
|
||||
})
|
||||
|
||||
it('shows a scope-specific confirmation message for each delete button', () => {
|
||||
render(<PeriodPopover {...defaultProps} wish={wish} />)
|
||||
openDeleteMenu()
|
||||
fireEvent.click(screen.getByTestId('period-delete-year-button'))
|
||||
expect(screen.getByTestId('period-delete-confirm')).toHaveTextContent(
|
||||
'Är du säker på att du vill ta bort den här perioden (bara det här året)?',
|
||||
@@ -519,7 +569,9 @@ describe('PeriodPopover', () => {
|
||||
describe('close behaviour', () => {
|
||||
it('calls onClose on the close button', () => {
|
||||
render(<PeriodPopover {...defaultProps} />)
|
||||
fireEvent.click(screen.getByLabelText('Stäng'))
|
||||
const closeButton = screen.getByLabelText('Stäng')
|
||||
expect(closeButton.querySelector('[data-icon="close"]')).toBeInTheDocument()
|
||||
fireEvent.click(closeButton)
|
||||
expect(mockOnClose).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ import { ReactNode, useEffect, useState } from 'react'
|
||||
import type { LeaveWish, LeaveWishScope } from '@/types'
|
||||
import { useTranslation, type TranslationKey } from '@/i18n'
|
||||
import { resolveWishName } from '@/utils/wishName'
|
||||
import { Icon } from '@/components/common/Icon'
|
||||
import './period-popover.css'
|
||||
|
||||
export interface PeriodPopoverSavePayload {
|
||||
@@ -155,6 +156,10 @@ export function PeriodPopover({
|
||||
const [form, setForm] = useState<FormState>({ ...EMPTY_FORM, startDate, endDate })
|
||||
const [localError, setLocalError] = useState<string | null>(null)
|
||||
const [deleteScope, setDeleteScope] = useState<PeriodDeleteScope | null>(null)
|
||||
// "Ta bort…" reveals the three delete scopes stacked under the footer;
|
||||
// they stay revealed while a scope's confirmation is cancelled, so the
|
||||
// user lands back on the choice they were making.
|
||||
const [isDeleteMenuOpen, setIsDeleteMenuOpen] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (wish) {
|
||||
@@ -164,6 +169,7 @@ export function PeriodPopover({
|
||||
}
|
||||
setLocalError(null)
|
||||
setDeleteScope(null)
|
||||
setIsDeleteMenuOpen(false)
|
||||
}, [isOpen, wish, startDate, endDate, t])
|
||||
|
||||
if (!isOpen) return null
|
||||
@@ -253,7 +259,7 @@ export function PeriodPopover({
|
||||
onClick={onClose}
|
||||
aria-label={t('calendar.periodCloseAria')}
|
||||
>
|
||||
×
|
||||
<Icon name="close" size={20} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -496,37 +502,16 @@ export function PeriodPopover({
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="period-popover__delete-group btn--start" data-testid="period-delete-group">
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn--danger"
|
||||
onClick={() => handleDeleteClick('day')}
|
||||
disabled={isSaving}
|
||||
title={t('calendar.periodDeleteDayHint')}
|
||||
data-testid="period-delete-day-button"
|
||||
>
|
||||
{t('calendar.periodDeleteDayButton')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn--danger"
|
||||
onClick={() => handleDeleteClick('year')}
|
||||
disabled={isSaving}
|
||||
data-testid="period-delete-year-button"
|
||||
>
|
||||
{wish ? t('calendar.periodDeleteYearButton') : t('calendar.periodDeleteAdHocButton')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn--danger"
|
||||
onClick={() => handleDeleteClick('allYears')}
|
||||
disabled={isSaving || wish === null}
|
||||
title={wish === null ? t('calendar.periodDeleteAllYearsDisabledHint') : undefined}
|
||||
data-testid="period-delete-all-years-button"
|
||||
>
|
||||
{t('calendar.periodDeleteAllYearsButton')}
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn--secondary btn--start period-popover__delete-menu-button"
|
||||
onClick={() => setIsDeleteMenuOpen((open) => !open)}
|
||||
disabled={isSaving}
|
||||
aria-expanded={isDeleteMenuOpen}
|
||||
data-testid="period-delete-menu-button"
|
||||
>
|
||||
{t('calendar.periodDeleteMenuButton')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn--secondary"
|
||||
@@ -547,6 +532,39 @@ export function PeriodPopover({
|
||||
{isSaving ? t('calendar.periodSavingButton') : t('calendar.periodSaveButton')}
|
||||
</button>
|
||||
)}
|
||||
{isDeleteMenuOpen && (
|
||||
<div className="period-popover__delete-group" data-testid="period-delete-group">
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn--secondary btn--block period-popover__delete-option"
|
||||
onClick={() => handleDeleteClick('day')}
|
||||
disabled={isSaving}
|
||||
title={t('calendar.periodDeleteDayHint')}
|
||||
data-testid="period-delete-day-button"
|
||||
>
|
||||
{t('calendar.periodDeleteDayButton')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn--secondary btn--block period-popover__delete-option"
|
||||
onClick={() => handleDeleteClick('year')}
|
||||
disabled={isSaving}
|
||||
data-testid="period-delete-year-button"
|
||||
>
|
||||
{wish ? t('calendar.periodDeleteYearButton') : t('calendar.periodDeleteAdHocButton')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn--secondary btn--block period-popover__delete-option"
|
||||
onClick={() => handleDeleteClick('allYears')}
|
||||
disabled={isSaving || wish === null}
|
||||
title={wish === null ? t('calendar.periodDeleteAllYearsDisabledHint') : undefined}
|
||||
data-testid="period-delete-all-years-button"
|
||||
>
|
||||
{t('calendar.periodDeleteAllYearsButton')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -14,9 +14,20 @@ describe('PresetRail', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('renders the heading and hint', () => {
|
||||
it('renders as a labelled chip group with a visually hidden heading', () => {
|
||||
render(<PresetRail onSelectPreset={vi.fn()} />)
|
||||
expect(screen.getByTestId('preset-rail')).toBeInTheDocument()
|
||||
const group = screen.getByTestId('preset-rail')
|
||||
expect(group).toHaveAttribute('role', 'group')
|
||||
expect(group).toHaveAttribute('aria-label', 'Lägg till ledighet')
|
||||
expect(screen.getByRole('heading', { level: 3 })).toHaveClass('visually-hidden')
|
||||
})
|
||||
|
||||
it('renders each preset as a chip with a plus icon and an "add" label', () => {
|
||||
render(<PresetRail onSelectPreset={vi.fn()} />)
|
||||
const chip = screen.getByTestId('preset-rail-season-sommar')
|
||||
expect(chip).toHaveClass('btn--chip')
|
||||
expect(chip.querySelector('[data-icon="plus"]')).toBeInTheDocument()
|
||||
expect(chip).toHaveAttribute('aria-label', 'Lägg till Sommar')
|
||||
})
|
||||
|
||||
it('calls onSelectPreset with the clicked preset', () => {
|
||||
@@ -52,10 +63,13 @@ describe('PresetRail', () => {
|
||||
expect(onSelectPreset).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('shows the week count and the flexible badge for each preset', () => {
|
||||
it('shows the week count on the chip and the full detail as its title', () => {
|
||||
render(<PresetRail onSelectPreset={vi.fn()} />)
|
||||
const summerButton = screen.getByTestId('preset-rail-season-sommar')
|
||||
expect(summerButton).toHaveTextContent('6')
|
||||
expect(summerButton).toHaveTextContent('när det passar bäst')
|
||||
expect(summerButton).toHaveTextContent('Sommar')
|
||||
expect(summerButton).toHaveTextContent('6 v')
|
||||
expect(summerButton).toHaveAttribute('title', '6 veckor · när det passar bäst')
|
||||
// Singular week unit for the one-week presets.
|
||||
expect(screen.getByTestId('preset-rail-season-var')).toHaveAttribute('title', '1 vecka · när det passar bäst')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { ReactNode } from 'react'
|
||||
import { useTranslation, type TranslationKey } from '@/i18n'
|
||||
import { Icon } from '@/components/common/Icon'
|
||||
import './preset-rail.css'
|
||||
|
||||
export interface SeasonPreset {
|
||||
@@ -35,39 +36,45 @@ export interface PresetRailProps {
|
||||
}
|
||||
|
||||
/**
|
||||
* A side rail of one-click season presets. Clicking one creates a flexible
|
||||
* `LeaveWish` and syncs the plan — the allocator picks the best placement,
|
||||
* and the calendar scrolls to it and flashes the resulting band (see
|
||||
* Calendar.tsx's `handleSelectPreset`). This rail only renders the choices
|
||||
* and reports which one was picked; it owns no request/scroll logic itself.
|
||||
* A row of one-click season presets ("+ Sommar · 6 v"), sitting above the
|
||||
* month grid. Clicking one creates a flexible `LeaveWish` and syncs the
|
||||
* plan — the allocator picks the best placement, and the calendar scrolls
|
||||
* to it and flashes the resulting band (see Calendar.tsx's
|
||||
* `handleSelectPreset`). This row only renders the choices and reports
|
||||
* which one was picked; it owns no request/scroll logic itself.
|
||||
*
|
||||
* The component keeps its `PresetRail` name and `preset-rail*` test ids
|
||||
* from when it was a side rail, so the page test and the e2e suite need no
|
||||
* change.
|
||||
*/
|
||||
export function PresetRail({ isBusy = false, onSelectPreset }: PresetRailProps): ReactNode {
|
||||
const { t } = useTranslation()
|
||||
|
||||
return (
|
||||
<aside className="preset-rail" data-testid="preset-rail" aria-label={t('calendar.railHeading')}>
|
||||
<h3 className="preset-rail__heading">{t('calendar.railHeading')}</h3>
|
||||
<p className="preset-rail__hint">{t('calendar.railHint')}</p>
|
||||
<div className="preset-rail__buttons">
|
||||
{SEASON_PRESETS.map((preset) => (
|
||||
<button
|
||||
key={preset.key}
|
||||
type="button"
|
||||
className="preset-rail__button"
|
||||
onClick={() => void onSelectPreset(preset)}
|
||||
disabled={isBusy}
|
||||
data-testid={`preset-rail-${preset.key}`}
|
||||
aria-label={t('calendar.presetAddAria', { name: t(preset.labelKey) })}
|
||||
>
|
||||
<span className="preset-rail__button-name">{t(preset.labelKey)}</span>
|
||||
<span className="preset-rail__button-detail">
|
||||
{preset.defaultWeeks} {t(preset.defaultWeeks === 1 ? 'wishEditor.weekUnit' : 'wishEditor.weeksUnit')}
|
||||
{' · '}
|
||||
{t('wishEditor.flexBadge')}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
<div className="preset-chips" data-testid="preset-rail" role="group" aria-label={t('calendar.presetChipsLabel')}>
|
||||
<h3 className="visually-hidden">{t('calendar.railHeading')}</h3>
|
||||
<p className="visually-hidden">{t('calendar.railHint')}</p>
|
||||
<div className="preset-chips__row">
|
||||
{SEASON_PRESETS.map((preset) => {
|
||||
const weeks = `${preset.defaultWeeks} ${t(preset.defaultWeeks === 1 ? 'wishEditor.weekUnit' : 'wishEditor.weeksUnit')}`
|
||||
return (
|
||||
<button
|
||||
key={preset.key}
|
||||
type="button"
|
||||
className="btn btn--chip preset-chips__chip"
|
||||
onClick={() => void onSelectPreset(preset)}
|
||||
disabled={isBusy}
|
||||
data-testid={`preset-rail-${preset.key}`}
|
||||
aria-label={t('calendar.presetAddAria', { name: t(preset.labelKey) })}
|
||||
title={`${weeks} · ${t('wishEditor.flexBadge')}`}
|
||||
>
|
||||
<Icon name="plus" size={16} />
|
||||
<span className="preset-chips__name">{t(preset.labelKey)}</span>
|
||||
<span className="preset-chips__meta">{t('calendar.presetWeeksShort', { count: preset.defaultWeeks })}</span>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
/* The "?" help popover beside the month title. The panel is a floating
|
||||
* layer, so it is one of the few places --shadow-float appears; the samples
|
||||
* inside it reuse the grid's own day/band rules so they never drift from
|
||||
* what the calendar actually draws. */
|
||||
.calendar-help {
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.calendar-help__panel {
|
||||
position: absolute;
|
||||
top: calc(100% + var(--sp-1));
|
||||
right: 0;
|
||||
z-index: var(--z-popover);
|
||||
width: min(320px, calc(100vw - var(--sp-6)));
|
||||
padding: var(--sp-4);
|
||||
background-color: var(--c-surface);
|
||||
border-radius: var(--radius-lg);
|
||||
box-shadow: var(--shadow-float);
|
||||
}
|
||||
|
||||
.calendar-help__title {
|
||||
margin: 0 0 var(--sp-2);
|
||||
font-size: var(--fs-sm);
|
||||
font-weight: 600;
|
||||
color: var(--c-text-muted);
|
||||
}
|
||||
|
||||
.calendar-help__title + .calendar-help__list + .calendar-help__title {
|
||||
margin-top: var(--sp-4);
|
||||
}
|
||||
|
||||
.calendar-help__list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.calendar-help__item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--sp-3);
|
||||
margin: 0;
|
||||
min-height: 28px;
|
||||
font-size: var(--fs-sm);
|
||||
color: var(--c-text);
|
||||
}
|
||||
|
||||
.calendar-help__label {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/* Day samples — the same recipe as `.calendar-day__number` and its
|
||||
* modifiers in calendar.css, at the same 14px. */
|
||||
.calendar-help__day {
|
||||
display: inline-flex;
|
||||
align-items: flex-start;
|
||||
gap: 2px;
|
||||
width: 44px;
|
||||
flex-shrink: 0;
|
||||
font-size: var(--fs-sm);
|
||||
font-weight: 500;
|
||||
font-variant-numeric: tabular-nums;
|
||||
color: var(--c-text);
|
||||
}
|
||||
|
||||
.calendar-help__numeral {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 24px;
|
||||
height: 24px;
|
||||
}
|
||||
|
||||
.calendar-help__sup {
|
||||
font-size: var(--fs-xs);
|
||||
font-weight: 600;
|
||||
line-height: 1;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.calendar-help__day--red {
|
||||
color: var(--c-day-red);
|
||||
}
|
||||
|
||||
.calendar-help__day--klamdag {
|
||||
color: var(--c-day-klamdag);
|
||||
}
|
||||
|
||||
.calendar-help__day--saturday {
|
||||
color: var(--c-day-saturday);
|
||||
}
|
||||
|
||||
.calendar-help__day--today .calendar-help__numeral {
|
||||
background-color: var(--c-accent);
|
||||
color: var(--c-surface);
|
||||
border-radius: var(--radius-full);
|
||||
}
|
||||
|
||||
.calendar-help__day--override {
|
||||
color: var(--c-text-muted);
|
||||
}
|
||||
|
||||
.calendar-help__day--override .calendar-help__numeral {
|
||||
color: var(--c-text);
|
||||
text-decoration: underline dotted;
|
||||
text-underline-offset: 3px;
|
||||
}
|
||||
|
||||
.calendar-help__day--excluded .calendar-help__numeral {
|
||||
text-decoration: line-through;
|
||||
color: var(--c-text-muted);
|
||||
}
|
||||
|
||||
/* Band samples — same fills/inks/outline as `.calendar-band--*`. */
|
||||
.calendar-help__band {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 44px;
|
||||
height: 18px;
|
||||
flex-shrink: 0;
|
||||
border-radius: var(--radius-band);
|
||||
font-size: var(--fs-xs);
|
||||
font-weight: 600;
|
||||
background-color: var(--c-leave-vacation);
|
||||
color: var(--c-leave-vacation-ink);
|
||||
}
|
||||
|
||||
.calendar-help__band--sjukpenning {
|
||||
background-color: var(--c-leave-sjukpenning);
|
||||
color: var(--c-leave-sjukpenning-ink);
|
||||
}
|
||||
|
||||
.calendar-help__band--lagsta {
|
||||
background-color: var(--c-leave-lagsta);
|
||||
background-image: repeating-linear-gradient(
|
||||
135deg,
|
||||
transparent 0 4px,
|
||||
var(--c-leave-lagsta-hatch) 4px 5px
|
||||
);
|
||||
box-shadow: inset 0 0 0 1px var(--c-leave-lagsta-outline);
|
||||
color: var(--c-leave-lagsta-ink);
|
||||
}
|
||||
|
||||
.calendar-help__band--self {
|
||||
box-shadow: inset 2px 0 0 var(--c-band-cap-self);
|
||||
}
|
||||
|
||||
.calendar-help__band--partner {
|
||||
box-shadow: inset 2px 0 0 var(--c-band-cap-partner);
|
||||
}
|
||||
|
||||
.calendar-help__band--pinned {
|
||||
box-shadow: inset 0 -2px 0 var(--c-leave-vacation-ink);
|
||||
}
|
||||
@@ -1,3 +1,6 @@
|
||||
/* Period popover — the modal shell, fields, buttons and the inline delete
|
||||
* confirmation all come from primitives.css; this file only holds what is
|
||||
* specific to editing a period. */
|
||||
.period-popover__adhoc-notice {
|
||||
font-size: var(--fs-sm);
|
||||
color: var(--c-text-muted);
|
||||
@@ -8,12 +11,13 @@
|
||||
font-size: var(--fs-base);
|
||||
font-weight: 500;
|
||||
color: var(--c-text);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.period-popover__fieldset {
|
||||
border: none;
|
||||
padding: 0;
|
||||
margin: 0 0 var(--sp-4) 0;
|
||||
margin: 0 0 var(--sp-2) 0;
|
||||
}
|
||||
|
||||
.period-popover__radio-label,
|
||||
@@ -21,6 +25,7 @@
|
||||
display: flex;
|
||||
align-items: center;
|
||||
font-size: var(--fs-sm);
|
||||
font-weight: 400;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
margin-bottom: var(--sp-2);
|
||||
@@ -38,18 +43,28 @@
|
||||
|
||||
.period-popover__hint {
|
||||
font-size: var(--fs-xs);
|
||||
color: var(--c-text-faint);
|
||||
margin: calc(var(--sp-2) * -1) 0 var(--sp-2) var(--sp-6);
|
||||
font-style: italic;
|
||||
color: var(--c-text-muted);
|
||||
margin: 0 0 var(--sp-2) var(--sp-5);
|
||||
}
|
||||
|
||||
/* todo.md #9 — the three-way remove: the delete-scope buttons sit side by
|
||||
* side, same row as the other footer buttons. */
|
||||
/* "Ta bort…" reveals the three delete scopes as a stack under the footer's
|
||||
* own row: the group takes the full footer width and sorts itself last, so
|
||||
* the Cancel/Save pair keeps its place above it. */
|
||||
.period-popover__delete-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--sp-2);
|
||||
flex-basis: 100%;
|
||||
order: 10;
|
||||
padding-top: var(--sp-2);
|
||||
}
|
||||
|
||||
/* Footer, buttons and the inline delete-confirmation row are shared with
|
||||
* the other modals via .modal__footer / .btn* / .modal__delete-confirm* in
|
||||
* src/styles/primitives.css — see that file for the button formula. */
|
||||
.period-popover__delete-option {
|
||||
color: var(--c-danger);
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.period-popover__delete-option:hover:not(:disabled) {
|
||||
border-color: var(--c-danger);
|
||||
color: var(--c-danger);
|
||||
}
|
||||
|
||||
@@ -1,63 +1,28 @@
|
||||
.preset-rail {
|
||||
width: 200px;
|
||||
flex-shrink: 0;
|
||||
padding: var(--sp-4);
|
||||
background-color: var(--c-surface);
|
||||
border: 1px solid var(--c-border);
|
||||
border-radius: var(--radius-lg);
|
||||
align-self: flex-start;
|
||||
/* The season presets as a chip row above the month grid. The chips
|
||||
* themselves are the shared `.btn--chip` primitive; this file only lays the
|
||||
* row out and sizes the muted "6 v" suffix. */
|
||||
.preset-chips {
|
||||
margin-bottom: var(--sp-4);
|
||||
}
|
||||
|
||||
.preset-rail__heading {
|
||||
margin: 0 0 var(--sp-1) 0;
|
||||
font-size: var(--fs-sm);
|
||||
font-weight: 600;
|
||||
color: var(--c-text);
|
||||
}
|
||||
|
||||
.preset-rail__hint {
|
||||
margin: 0 0 var(--sp-3) 0;
|
||||
font-size: var(--fs-xs);
|
||||
color: var(--c-text-muted);
|
||||
}
|
||||
|
||||
.preset-rail__buttons {
|
||||
.preset-chips__row {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--sp-2);
|
||||
}
|
||||
|
||||
.preset-rail__button {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 2px;
|
||||
padding: var(--sp-2) var(--sp-3);
|
||||
border: 1px solid var(--c-border);
|
||||
border-radius: var(--radius-sm);
|
||||
background-color: var(--c-surface);
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
transition: background-color 0.2s, border-color 0.2s;
|
||||
.preset-chips__chip {
|
||||
gap: var(--sp-1);
|
||||
padding-left: var(--sp-2);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.preset-rail__button:hover:not(:disabled) {
|
||||
background-color: var(--c-accent-weak);
|
||||
border-color: var(--c-accent);
|
||||
}
|
||||
|
||||
.preset-rail__button:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.preset-rail__button-name {
|
||||
font-size: var(--fs-sm);
|
||||
font-weight: 600;
|
||||
.preset-chips__name {
|
||||
color: var(--c-text);
|
||||
}
|
||||
|
||||
.preset-rail__button-detail {
|
||||
font-size: var(--fs-xs);
|
||||
color: var(--c-text-muted);
|
||||
.preset-chips__meta {
|
||||
color: var(--c-text-faint);
|
||||
font-weight: 400;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
@@ -36,7 +36,14 @@ describe('CalendarDay', () => {
|
||||
const { getByTestId } = render(<CalendarDay date="2026-06-15" />)
|
||||
const day = getByTestId('calendar-day')
|
||||
expect(day).toBeInTheDocument()
|
||||
expect(day.textContent).toContain('15')
|
||||
expect(day.querySelector('.calendar-day__number')).toHaveTextContent('15')
|
||||
})
|
||||
|
||||
it('keeps the R mark beside the numeral, inside the same head row', () => {
|
||||
const { getByTestId } = render(<CalendarDay date="2026-06-14" dayType="weekend" />)
|
||||
const head = getByTestId('calendar-day').querySelector('.calendar-day__head')
|
||||
expect(head?.querySelector('.calendar-day__number')).toHaveTextContent('14')
|
||||
expect(head?.querySelector('[data-testid="calendar-day-type-badge"]')).toHaveTextContent('R')
|
||||
})
|
||||
|
||||
it('renders as disabled when isCurrentMonth is false', () => {
|
||||
@@ -47,32 +54,6 @@ describe('CalendarDay', () => {
|
||||
expect(day).toHaveClass('calendar-day--disabled')
|
||||
})
|
||||
|
||||
it('renders events when provided', () => {
|
||||
const { getByTestId } = render(
|
||||
<CalendarDay
|
||||
date="2026-06-15"
|
||||
events={[
|
||||
{ type: 'vacation', color: '#FF5733', title: 'Vacation' },
|
||||
{ type: 'sick', color: '#FFD700', title: 'Sick leave' },
|
||||
]}
|
||||
/>
|
||||
)
|
||||
const events = getByTestId('calendar-day-events')
|
||||
expect(events).toBeInTheDocument()
|
||||
expect(events.children).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('applies has-events class when events exist', () => {
|
||||
const { getByTestId } = render(
|
||||
<CalendarDay
|
||||
date="2026-06-15"
|
||||
events={[{ type: 'vacation', color: '#FF5733' }]}
|
||||
/>
|
||||
)
|
||||
const day = getByTestId('calendar-day')
|
||||
expect(day).toHaveClass('calendar-day--has-events')
|
||||
})
|
||||
|
||||
it('calls onClick when clicked', () => {
|
||||
const onClick = vi.fn()
|
||||
const { getByTestId } = render(
|
||||
@@ -115,26 +96,6 @@ describe('CalendarDay', () => {
|
||||
expect(onClick).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('provides accessibility label with event count', () => {
|
||||
const { getByTestId } = render(
|
||||
<CalendarDay
|
||||
date="2026-06-15"
|
||||
events={[
|
||||
{ type: 'vacation', color: '#FF5733' },
|
||||
{ type: 'sick', color: '#FFD700' },
|
||||
]}
|
||||
/>
|
||||
)
|
||||
const day = getByTestId('calendar-day')
|
||||
expect(day).toHaveAttribute('aria-label', '2026-06-15 med 2 händelse(r)')
|
||||
})
|
||||
|
||||
it('renders without events by default', () => {
|
||||
const { queryByTestId } = render(<CalendarDay date="2026-06-15" />)
|
||||
const events = queryByTestId('calendar-day-events')
|
||||
expect(events).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('applies today class when isToday is true', () => {
|
||||
const { getByTestId } = render(
|
||||
<CalendarDay date="2026-06-15" isToday={true} />
|
||||
@@ -234,23 +195,25 @@ describe('CalendarDay', () => {
|
||||
})
|
||||
|
||||
describe('§3.6 workspace day overrides', () => {
|
||||
it('marks an override with a distinct class and badge, working-day variant', () => {
|
||||
it('marks an override with a distinct class and a briefcase icon, working-day variant', () => {
|
||||
const { getByTestId } = render(
|
||||
<CalendarDay date="2026-06-15" isOverride={true} overrideIsWorkingDay={true} />
|
||||
)
|
||||
const day = getByTestId('calendar-day')
|
||||
expect(day).toHaveClass('calendar-day--override')
|
||||
expect(day.getAttribute('aria-label')).toContain('arbetsdag')
|
||||
expect(getByTestId('calendar-day-override-badge')).toBeInTheDocument()
|
||||
const badge = getByTestId('calendar-day-override-badge')
|
||||
expect(badge.querySelector('[data-icon="briefcase"]')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('marks an override with a distinct class and badge, free-day variant', () => {
|
||||
it('marks an override with a distinct class and a check icon, free-day variant', () => {
|
||||
const { getByTestId } = render(
|
||||
<CalendarDay date="2026-06-15" isOverride={true} overrideIsWorkingDay={false} />
|
||||
)
|
||||
const day = getByTestId('calendar-day')
|
||||
expect(day).toHaveClass('calendar-day--override')
|
||||
expect(day.getAttribute('aria-label')).toContain('ledig dag')
|
||||
expect(getByTestId('calendar-day-override-badge').querySelector('[data-icon="check"]')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('combines a statutory red day with an override distinctly', () => {
|
||||
@@ -272,11 +235,13 @@ describe('CalendarDay', () => {
|
||||
})
|
||||
|
||||
describe('user-removed days (PlannedDayExclusion)', () => {
|
||||
it('marks a removed day with its own class, badge and aria descriptor', () => {
|
||||
it('marks a removed day with its own class, a hidden badge hook and an aria descriptor', () => {
|
||||
const { getByTestId } = render(<CalendarDay date="2026-06-15" isExcluded={true} />)
|
||||
const day = getByTestId('calendar-day')
|
||||
expect(day).toHaveClass('calendar-day--excluded')
|
||||
expect(getByTestId('calendar-day-excluded-badge')).toBeInTheDocument()
|
||||
// The strike-through numeral is the visible mark; the badge only
|
||||
// keeps a findable hook and stays out of sight.
|
||||
expect(getByTestId('calendar-day-excluded-badge')).toHaveClass('visually-hidden')
|
||||
expect(day.getAttribute('aria-label')).toContain('borttagen dag')
|
||||
})
|
||||
|
||||
|
||||
@@ -2,10 +2,10 @@ import { ReactNode } from 'react'
|
||||
import { useTranslation } from '@/i18n'
|
||||
import { formatHolidayName } from '@/utils/dates'
|
||||
import type { CalendarDayType } from '@/types'
|
||||
import { Icon } from './Icon'
|
||||
|
||||
export interface CalendarDayProps {
|
||||
date: string
|
||||
events?: { type: string; color: string; title?: string }[] | undefined
|
||||
isCurrentMonth?: boolean | undefined
|
||||
onClick?: (() => void) | undefined
|
||||
isToday?: boolean | undefined
|
||||
@@ -28,9 +28,16 @@ export interface CalendarDayProps {
|
||||
isExcluded?: boolean | undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* One day cell. Every state is carried by the numeral, never by a cell fill
|
||||
* (see calendar.css): a red day is a red numeral with a small "R", a
|
||||
* klämdag an ochre numeral with a "K", today a filled circle, an override a
|
||||
* dotted underline plus a 16px icon, a removed day a struck-through numeral.
|
||||
* The R/K/removed marks keep their `data-testid`s so the e2e suite and the
|
||||
* unit tests can find them regardless of how they are drawn.
|
||||
*/
|
||||
export function CalendarDay({
|
||||
date,
|
||||
events = [],
|
||||
isCurrentMonth = true,
|
||||
onClick,
|
||||
isToday = false,
|
||||
@@ -92,14 +99,12 @@ export function CalendarDay({
|
||||
descriptors.push(t('calendarDay.excludedAria'))
|
||||
}
|
||||
|
||||
const baseLabel = events.length > 0 ? t('calendarDay.ariaWithEvents', { date, count: events.length }) : date
|
||||
const ariaLabel = descriptors.length > 0 ? `${baseLabel}, ${descriptors.join(', ')}` : baseLabel
|
||||
const ariaLabel = descriptors.length > 0 ? `${date}, ${descriptors.join(', ')}` : date
|
||||
const title = descriptors.length > 0 ? descriptors.join(' · ') : undefined
|
||||
|
||||
const className = [
|
||||
'calendar-day',
|
||||
!isCurrentMonth ? 'calendar-day--disabled' : '',
|
||||
events.length > 0 ? 'calendar-day--has-events' : '',
|
||||
isToday ? 'calendar-day--today' : '',
|
||||
isSelected ? 'calendar-day--selected' : '',
|
||||
isRedDay ? 'calendar-day--red-day' : '',
|
||||
@@ -137,44 +142,35 @@ export function CalendarDay({
|
||||
}}
|
||||
onPointerEnter={() => onPointerEnter?.(date)}
|
||||
>
|
||||
<div className="calendar-day__number">{dayNum}</div>
|
||||
{(isRedDay || dayType === 'klamdag') && (
|
||||
<span className="calendar-day__type-badge" data-testid="calendar-day-type-badge" aria-hidden="true">
|
||||
{dayType === 'klamdag' ? 'K' : 'R'}
|
||||
</span>
|
||||
)}
|
||||
<div className="calendar-day__head">
|
||||
<span className="calendar-day__number">{dayNum}</span>
|
||||
{(isRedDay || dayType === 'klamdag') && (
|
||||
<span className="calendar-day__type-badge" data-testid="calendar-day-type-badge" aria-hidden="true">
|
||||
{dayType === 'klamdag' ? 'K' : 'R'}
|
||||
</span>
|
||||
)}
|
||||
{isOverride && (
|
||||
<span className="calendar-day__override-badge" data-testid="calendar-day-override-badge" aria-hidden="true">
|
||||
<Icon name={overrideIsWorkingDay ? 'briefcase' : 'check'} size={16} />
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{cleanHolidayName && (
|
||||
<div className="calendar-day__holiday-name" data-testid="calendar-day-holiday-name" title={cleanHolidayName}>
|
||||
{cleanHolidayName}
|
||||
</div>
|
||||
)}
|
||||
{isOverride && (
|
||||
<span className="calendar-day__override-badge" data-testid="calendar-day-override-badge" aria-hidden="true">
|
||||
{overrideIsWorkingDay ? '⚒' : '✓'}
|
||||
</span>
|
||||
)}
|
||||
{isExcluded && (
|
||||
// The strike-through numeral is the visible mark; this span only
|
||||
// keeps a findable hook for tests and the e2e suite.
|
||||
<span
|
||||
className="calendar-day__excluded-badge"
|
||||
className="calendar-day__excluded-badge visually-hidden"
|
||||
data-testid="calendar-day-excluded-badge"
|
||||
aria-hidden="true"
|
||||
>
|
||||
⊘
|
||||
</span>
|
||||
)}
|
||||
{events.length > 0 && (
|
||||
<div className="calendar-day__events" data-testid="calendar-day-events">
|
||||
{events.map((event, idx) => (
|
||||
<div
|
||||
key={idx}
|
||||
className="calendar-day__event"
|
||||
style={{ backgroundColor: event.color }}
|
||||
title={event.title ?? event.type}
|
||||
data-testid={`event-${idx}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -33,7 +33,8 @@ describe('CalendarEvent', () => {
|
||||
)
|
||||
const button = getByTestId('dismiss-button')
|
||||
expect(button).toBeInTheDocument()
|
||||
expect(button.textContent).toBe('×')
|
||||
expect(button).toHaveAttribute('aria-label', 'Avfärda vacation')
|
||||
expect(button.querySelector('[data-icon="close"]')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('does not render dismiss button when onDismiss not provided', () => {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { ReactNode } from 'react'
|
||||
import { useTranslation } from '@/i18n'
|
||||
import { Icon } from './Icon'
|
||||
|
||||
export interface CalendarEventProps {
|
||||
leaveType: string
|
||||
@@ -51,7 +52,7 @@ export function CalendarEvent({
|
||||
aria-label={t('calendarEvent.dismissAria', { leaveType })}
|
||||
data-testid="dismiss-button"
|
||||
>
|
||||
×
|
||||
<Icon name="close" size={16} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -411,6 +411,57 @@ describe('CalendarWeek', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('band modifiers: type by fill/ink/outline, owner by the left cap', () => {
|
||||
it('names the representative funding type as a bare modifier', () => {
|
||||
const plannedDays = [pd({ id: '1', date: '2026-01-05', dayType: 'lagsta' })]
|
||||
render(<CalendarWeek {...defaultProps} plannedDays={plannedDays} />)
|
||||
const band = screen.getAllByTestId(/^calendar-band-/)[0]
|
||||
expect(band).toHaveClass('calendar-band--lagsta')
|
||||
expect(band).not.toHaveClass('calendar-band--vacation')
|
||||
})
|
||||
|
||||
it('marks the logged-in parent\'s band --self and anyone else\'s --partner', () => {
|
||||
const plannedDays = [
|
||||
pd({ id: '1', date: '2026-01-05', parentPersonalDataUuid: 'parent-1' }),
|
||||
pd({ id: '2', date: '2026-01-06', parentPersonalDataUuid: 'other-parent' }),
|
||||
]
|
||||
render(<CalendarWeek {...defaultProps} plannedDays={plannedDays} />)
|
||||
const bands = screen.getAllByTestId(/^calendar-band-/)
|
||||
const own = bands.find((b) => b.getAttribute('data-testid')?.includes('parent-1'))
|
||||
const partner = bands.find((b) => b.getAttribute('data-testid')?.includes('other-parent'))
|
||||
expect(own).toHaveClass('calendar-band--self')
|
||||
expect(own).not.toHaveClass('calendar-band--partner')
|
||||
expect(partner).toHaveClass('calendar-band--partner')
|
||||
expect(partner).not.toHaveClass('calendar-band--self')
|
||||
})
|
||||
|
||||
it('gives each funded tick its own type modifier, and none to a bridged gap', () => {
|
||||
const plannedDays = [
|
||||
pd({ id: '1', date: '2026-01-09', wishId: 'wish-1', dayType: 'sjukpenning' }),
|
||||
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} />,
|
||||
)
|
||||
const ticks = Array.from(container.querySelectorAll('.calendar-band__day'))
|
||||
expect(ticks[0]).toHaveClass('calendar-band__day--sjukpenning')
|
||||
expect(ticks[1]).toHaveClass('calendar-band__day--gap')
|
||||
expect(ticks[1]?.className).not.toMatch(/calendar-band__day--(vacation|sjukpenning|lagsta)/)
|
||||
})
|
||||
|
||||
it('sets the band height and lane offsets from the 18px bar geometry', () => {
|
||||
const plannedDays = [pd({ id: '1', date: '2026-01-05' })]
|
||||
render(<CalendarWeek {...defaultProps} plannedDays={plannedDays} />)
|
||||
const band = screen.getAllByTestId(/^calendar-band-/)[0]!
|
||||
expect(band.style.height).toBe('18px')
|
||||
expect(band.style.bottom).toBe('4px')
|
||||
})
|
||||
})
|
||||
|
||||
describe('band caps: rounded only at the true start/end of the whole period', () => {
|
||||
it('rounds both ends when the whole band fits inside this week', () => {
|
||||
const plannedDays = [pd({ id: '1', date: '2026-01-06' }), pd({ id: '2', date: '2026-01-07' })]
|
||||
@@ -642,8 +693,8 @@ describe('CalendarWeek', () => {
|
||||
// every row in the month renders the same height.
|
||||
const { container } = render(<CalendarWeek {...defaultProps} plannedDays={[]} monthMaxLaneCount={2} />)
|
||||
const week = container.querySelector('.calendar-week') as HTMLElement
|
||||
// bandBottomInset(4) + 2 lanes * (barHeight(20) + laneGap(2)) = 48px
|
||||
expect(week.style.getPropertyValue('--band-reserved-height')).toBe('48px')
|
||||
// bandBottomInset(4) + 2 lanes * (barHeight(18) + laneGap(2)) = 44px
|
||||
expect(week.style.getPropertyValue('--band-reserved-height')).toBe('44px')
|
||||
})
|
||||
|
||||
it('does not shrink below what this week\'s own bands need, even if monthMaxLaneCount is smaller', () => {
|
||||
@@ -660,15 +711,15 @@ describe('CalendarWeek', () => {
|
||||
)
|
||||
const week = container.querySelector('.calendar-week') as HTMLElement
|
||||
// This week's own 2 lanes win over the smaller monthMaxLaneCount.
|
||||
expect(week.style.getPropertyValue('--band-reserved-height')).toBe('48px')
|
||||
expect(week.style.getPropertyValue('--band-reserved-height')).toBe('44px')
|
||||
})
|
||||
|
||||
it('falls back to this week\'s own lane count when monthMaxLaneCount is omitted (single-week render)', () => {
|
||||
const plannedDays = [pd({ id: '1', date: '2026-01-05' })]
|
||||
const { container } = render(<CalendarWeek {...defaultProps} plannedDays={plannedDays} />)
|
||||
const week = container.querySelector('.calendar-week') as HTMLElement
|
||||
// bandBottomInset(4) + 1 lane * (barHeight(20) + laneGap(2)) = 26px
|
||||
expect(week.style.getPropertyValue('--band-reserved-height')).toBe('26px')
|
||||
// bandBottomInset(4) + 1 lane * (barHeight(18) + laneGap(2)) = 24px
|
||||
expect(week.style.getPropertyValue('--band-reserved-height')).toBe('24px')
|
||||
})
|
||||
|
||||
it('gives every week row the same --band-reserved-height when rendered together with a shared monthMaxLaneCount, even though only one has bands', () => {
|
||||
@@ -695,7 +746,7 @@ describe('CalendarWeek', () => {
|
||||
expect(weeks).toHaveLength(2)
|
||||
const heights = weeks.map((week) => week.style.getPropertyValue('--band-reserved-height'))
|
||||
expect(new Set(heights).size).toBe(1)
|
||||
expect(heights[0]).toBe('48px')
|
||||
expect(heights[0]).toBe('44px')
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { ReactNode, useMemo } from 'react'
|
||||
import type { PlannedLeaveDay, CalendarDayEntry } from '@/types'
|
||||
import { getColorForType, getInkForType, getLabelForType, getTypeModifier } from '@/utils/leaveTypes'
|
||||
import { getColorForType, getInkForType, getLabelForType, getTypeKey } from '@/utils/leaveTypes'
|
||||
import { getISOWeekNumber, formatDateLocalISO } from '@/utils/dates'
|
||||
import { useTranslation } from '@/i18n'
|
||||
import { CalendarDay } from './CalendarDay'
|
||||
@@ -311,7 +311,7 @@ export function CalendarWeek({
|
||||
// edge AND, via `bandsReservedHeight`, how much bottom padding
|
||||
// `.calendar-day` reserves so its number/badges/holiday name never sit
|
||||
// under a band.
|
||||
const barHeight = 20
|
||||
const barHeight = 18
|
||||
const laneGap = 2
|
||||
const bandBottomInset = 4
|
||||
const laneCount = bandSegments.length > 0 ? Math.max(...bandSegments.map((b) => b.lane)) + 1 : 0
|
||||
@@ -398,9 +398,14 @@ export function CalendarWeek({
|
||||
const ink = getInkForType(representativeType)
|
||||
const anyDubbeldag = segment.dayInfos.some((d) => !d.isGap && d.isDubbeldag)
|
||||
|
||||
// Type by fill/ink/outline (`--vacation|--sjukpenning|--lagsta`,
|
||||
// see the --c-leave-* tokens) and owner by a 2px left cap
|
||||
// (`--self`/`--partner`) — never by hue, so the two axes stay
|
||||
// readable side by side and in greyscale.
|
||||
const classNames = [
|
||||
'calendar-band',
|
||||
`calendar-band--${getTypeModifier(representativeType)}`,
|
||||
`calendar-band--${getTypeKey(representativeType)}`,
|
||||
segment.parentUuid === personalDataUuid ? 'calendar-band--self' : 'calendar-band--partner',
|
||||
segment.isBandStart ? 'calendar-band--start' : '',
|
||||
segment.isBandEnd ? 'calendar-band--end' : '',
|
||||
segment.wishId && segment.wishId === flashWishId ? 'calendar-band--flash' : '',
|
||||
@@ -453,7 +458,7 @@ export function CalendarWeek({
|
||||
key={info.date}
|
||||
className={[
|
||||
'calendar-band__day',
|
||||
info.isGap ? 'calendar-band__day--gap' : '',
|
||||
info.isGap ? 'calendar-band__day--gap' : `calendar-band__day--${getTypeKey(info.dayType ?? 'vacation')}`,
|
||||
info.isPinned ? 'calendar-band__day--pinned' : '',
|
||||
]
|
||||
.filter(Boolean)
|
||||
|
||||
@@ -16,6 +16,8 @@ describe('PlannedDayModal', () => {
|
||||
{ id: 'p2', name: 'Parent 2' },
|
||||
]
|
||||
|
||||
// A solo household: the type select is shown directly (no "Advanced"
|
||||
// disclosure), defaulting to "let the planner choose".
|
||||
const defaultProps = {
|
||||
date: '2026-06-15',
|
||||
isOpen: true,
|
||||
@@ -27,14 +29,23 @@ describe('PlannedDayModal', () => {
|
||||
onClose: mockOnClose,
|
||||
}
|
||||
|
||||
// A couple: the pinned/exact controls sit behind the "Advanced" link.
|
||||
const coupleProps = { ...defaultProps, parents: twoParents }
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
/** Couple mode only — a solo household has no toggle (see `pickType`). */
|
||||
async function openAdvanced() {
|
||||
await userEvent.click(screen.getByTestId('advanced-toggle'))
|
||||
}
|
||||
|
||||
/** Solo mode's way onto the pinned path: pick a concrete type. */
|
||||
async function pickType(type: 'vacation' | 'sjukpenning' | 'lagsta') {
|
||||
await userEvent.selectOptions(screen.getByTestId('day-type-select'), type)
|
||||
}
|
||||
|
||||
describe('visibility and rendering', () => {
|
||||
it('returns null when modal is not open', () => {
|
||||
const { container } = render(<PlannedDayModal {...defaultProps} isOpen={false} />)
|
||||
@@ -46,10 +57,10 @@ describe('PlannedDayModal', () => {
|
||||
expect(screen.getByRole('dialog', { hidden: true })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders the default (planner-decides) controls when there is no existing day', () => {
|
||||
render(<PlannedDayModal {...defaultProps} isOpen={true} />)
|
||||
it('renders the default (planner-decides) controls when there is no existing day (couple)', () => {
|
||||
render(<PlannedDayModal {...coupleProps} isOpen={true} />)
|
||||
expect(screen.getByTestId('modal-title')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('advanced-toggle')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('advanced-toggle')).toHaveClass('btn--link')
|
||||
expect(screen.getByTestId('cancel-button')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('save-button')).toBeInTheDocument()
|
||||
// The pinned/exact-type controls stay hidden until Advanced is opened.
|
||||
@@ -60,8 +71,75 @@ describe('PlannedDayModal', () => {
|
||||
render(<PlannedDayModal {...defaultProps} isOpen={true} parents={[{ id: 'p1', name: 'Parent 1' }]} />)
|
||||
expect(screen.queryByTestId('parent-mode-select')).not.toBeInTheDocument()
|
||||
expect(screen.queryByTestId('wants-dubbeldag-checkbox')).not.toBeInTheDocument()
|
||||
// The advanced escape hatch is still available regardless of household size.
|
||||
expect(screen.getByTestId('advanced-toggle')).toBeInTheDocument()
|
||||
expect(screen.queryByTestId('parent-select')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('solo household — the type select is the only question, so it is shown directly', () => {
|
||||
it('shows the type select with "let the planner choose" preselected and no Advanced toggle', () => {
|
||||
render(<PlannedDayModal {...defaultProps} isOpen={true} />)
|
||||
expect(screen.queryByTestId('advanced-toggle')).not.toBeInTheDocument()
|
||||
const select = screen.getByTestId('day-type-select') as HTMLSelectElement
|
||||
expect(select).toHaveValue('auto')
|
||||
expect(Array.from(select.options).map((o) => o.value)).toEqual(['auto', 'vacation', 'sjukpenning', 'lagsta'])
|
||||
expect(select.options[0]).toHaveTextContent('Låt planeraren välja')
|
||||
// Nothing to pin yet, so the dubbeldag flag stays out of the way.
|
||||
expect(screen.queryByTestId('dubbeldag-checkbox')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('saves as a planner-funded wish for "either" parent while the planner-decides choice is kept', async () => {
|
||||
mockOnSaveWish.mockResolvedValue(undefined)
|
||||
render(<PlannedDayModal {...defaultProps} isOpen={true} />)
|
||||
await userEvent.click(screen.getByTestId('save-button'))
|
||||
await waitFor(() =>
|
||||
expect(mockOnSaveWish).toHaveBeenCalledWith({ parentMode: 'either', parentPersonalDataUuid: null, wantsDubbeldag: false }),
|
||||
)
|
||||
expect(mockOnSavePinned).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('picking a concrete type switches the save to the pinned path', async () => {
|
||||
mockOnSavePinned.mockResolvedValue(undefined)
|
||||
render(<PlannedDayModal {...defaultProps} isOpen={true} />)
|
||||
await pickType('sjukpenning')
|
||||
expect(screen.getByTestId('dubbeldag-checkbox')).toBeInTheDocument()
|
||||
await userEvent.click(screen.getByTestId('save-button'))
|
||||
await waitFor(() =>
|
||||
expect(mockOnSavePinned).toHaveBeenCalledWith({ dayType: 'sjukpenning', parentId: 'p1', isDubbeldag: false }),
|
||||
)
|
||||
expect(mockOnSaveWish).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('going back to "let the planner choose" clears the dubbeldag flag and returns to the wish path', async () => {
|
||||
mockOnSaveWish.mockResolvedValue(undefined)
|
||||
render(<PlannedDayModal {...defaultProps} isOpen={true} />)
|
||||
await pickType('lagsta')
|
||||
await userEvent.click(screen.getByTestId('dubbeldag-checkbox'))
|
||||
expect(screen.getByTestId('dubbeldag-checkbox')).toBeChecked()
|
||||
await userEvent.selectOptions(screen.getByTestId('day-type-select'), 'auto')
|
||||
expect(screen.queryByTestId('dubbeldag-checkbox')).not.toBeInTheDocument()
|
||||
await userEvent.click(screen.getByTestId('save-button'))
|
||||
await waitFor(() => expect(mockOnSaveWish).toHaveBeenCalled())
|
||||
expect(mockOnSavePinned).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('ignores defaultDayType for a new solo day — the planner-decides choice wins', () => {
|
||||
render(<PlannedDayModal {...defaultProps} isOpen={true} defaultDayType="sjukpenning" />)
|
||||
expect(screen.getByTestId('day-type-select')).toHaveValue('auto')
|
||||
})
|
||||
|
||||
it('editing an existing day offers only the three concrete types, without the planner option', () => {
|
||||
const existingDay = makePlannedLeaveDay({ id: 'day-1', parentPersonalDataUuid: 'p1', dayType: 'lagsta' })
|
||||
render(<PlannedDayModal {...defaultProps} isOpen={true} existingDay={existingDay} />)
|
||||
const select = screen.getByTestId('day-type-select') as HTMLSelectElement
|
||||
expect(Array.from(select.options).map((o) => o.value)).toEqual(['vacation', 'sjukpenning', 'lagsta'])
|
||||
expect(select).toHaveValue('lagsta')
|
||||
expect(screen.queryByTestId('advanced-toggle')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('a range selection keeps the select on the planner-decides choice', () => {
|
||||
const existingDay = makePlannedLeaveDay({ id: 'day-1', parentPersonalDataUuid: 'p1' })
|
||||
render(<PlannedDayModal {...defaultProps} isOpen={true} isRangeMode dayCount={3} existingDay={existingDay} />)
|
||||
expect(screen.getByTestId('day-type-select')).toHaveValue('auto')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -176,7 +254,7 @@ describe('PlannedDayModal', () => {
|
||||
})
|
||||
|
||||
it('the "Advanced" toggle reveals the pinned/exact controls without submitting', async () => {
|
||||
render(<PlannedDayModal {...defaultProps} isOpen={true} parents={twoParents} />)
|
||||
render(<PlannedDayModal {...coupleProps} isOpen={true} parents={twoParents} />)
|
||||
await openAdvanced()
|
||||
expect(screen.getByTestId('day-type-select')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('parent-select')).toBeInTheDocument()
|
||||
@@ -186,7 +264,7 @@ describe('PlannedDayModal', () => {
|
||||
|
||||
it('saving while Advanced is open calls onSavePinned, not onSaveWish', async () => {
|
||||
mockOnSavePinned.mockResolvedValue(undefined)
|
||||
render(<PlannedDayModal {...defaultProps} isOpen={true} parents={twoParents} />)
|
||||
render(<PlannedDayModal {...coupleProps} isOpen={true} parents={twoParents} />)
|
||||
await openAdvanced()
|
||||
await userEvent.click(screen.getByTestId('save-button'))
|
||||
await waitFor(() =>
|
||||
@@ -207,13 +285,13 @@ describe('PlannedDayModal', () => {
|
||||
|
||||
it('a range selection never forces Advanced open, even if the first date happens to carry an existing day', () => {
|
||||
const existingDay = makePlannedLeaveDay({ id: 'day-1', parentPersonalDataUuid: 'p1' })
|
||||
render(<PlannedDayModal {...defaultProps} isOpen={true} isRangeMode dayCount={3} existingDay={existingDay} />)
|
||||
render(<PlannedDayModal {...coupleProps} isOpen={true} isRangeMode dayCount={3} existingDay={existingDay} />)
|
||||
expect(screen.queryByTestId('day-type-select')).not.toBeInTheDocument()
|
||||
expect(screen.getByTestId('advanced-toggle')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('the advanced parent select is controlled by selectedParentId and reports changes via onParentChange', async () => {
|
||||
render(<PlannedDayModal {...defaultProps} isOpen={true} parents={twoParents} selectedParentId="p1" />)
|
||||
render(<PlannedDayModal {...coupleProps} isOpen={true} parents={twoParents} selectedParentId="p1" />)
|
||||
await openAdvanced()
|
||||
const select = screen.getByTestId('parent-select') as HTMLSelectElement
|
||||
expect(select).toHaveValue('p1')
|
||||
@@ -244,7 +322,7 @@ describe('PlannedDayModal', () => {
|
||||
|
||||
describe('day type selection (inside Advanced)', () => {
|
||||
it('renders day type select with all options', async () => {
|
||||
render(<PlannedDayModal {...defaultProps} isOpen={true} />)
|
||||
render(<PlannedDayModal {...coupleProps} isOpen={true} />)
|
||||
await openAdvanced()
|
||||
const select = screen.getByTestId('day-type-select') as HTMLSelectElement
|
||||
expect(select.options).toHaveLength(3)
|
||||
@@ -254,19 +332,19 @@ describe('PlannedDayModal', () => {
|
||||
})
|
||||
|
||||
it('defaults to vacation day type', async () => {
|
||||
render(<PlannedDayModal {...defaultProps} isOpen={true} />)
|
||||
render(<PlannedDayModal {...coupleProps} isOpen={true} />)
|
||||
await openAdvanced()
|
||||
expect(screen.getByTestId('day-type-select')).toHaveValue('vacation')
|
||||
})
|
||||
|
||||
it('uses defaultDayType when provided and no existing day', async () => {
|
||||
render(<PlannedDayModal {...defaultProps} isOpen={true} defaultDayType="sjukpenning" />)
|
||||
render(<PlannedDayModal {...coupleProps} isOpen={true} defaultDayType="sjukpenning" />)
|
||||
await openAdvanced()
|
||||
expect(screen.getByTestId('day-type-select')).toHaveValue('sjukpenning')
|
||||
})
|
||||
|
||||
it('allows changing day type', async () => {
|
||||
render(<PlannedDayModal {...defaultProps} isOpen={true} />)
|
||||
render(<PlannedDayModal {...coupleProps} isOpen={true} />)
|
||||
await openAdvanced()
|
||||
const select = screen.getByTestId('day-type-select') as HTMLSelectElement
|
||||
await userEvent.selectOptions(select, 'sjukpenning')
|
||||
@@ -274,9 +352,9 @@ describe('PlannedDayModal', () => {
|
||||
})
|
||||
|
||||
it('disables day type select when isSaving', async () => {
|
||||
const { rerender } = render(<PlannedDayModal {...defaultProps} isOpen={true} />)
|
||||
const { rerender } = render(<PlannedDayModal {...coupleProps} isOpen={true} />)
|
||||
await openAdvanced()
|
||||
rerender(<PlannedDayModal {...defaultProps} isOpen={true} isSaving={true} />)
|
||||
rerender(<PlannedDayModal {...coupleProps} isOpen={true} isSaving={true} />)
|
||||
expect(screen.getByTestId('day-type-select')).toBeDisabled()
|
||||
})
|
||||
})
|
||||
@@ -284,18 +362,18 @@ describe('PlannedDayModal', () => {
|
||||
describe('advanced parent select', () => {
|
||||
it('does not render parent select when only one parent', async () => {
|
||||
render(<PlannedDayModal {...defaultProps} isOpen={true} parents={[{ id: 'p1', name: 'Parent 1' }]} />)
|
||||
await openAdvanced()
|
||||
await pickType('vacation')
|
||||
expect(screen.queryByTestId('parent-select')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders parent select when multiple parents', async () => {
|
||||
render(<PlannedDayModal {...defaultProps} isOpen={true} parents={twoParents} />)
|
||||
render(<PlannedDayModal {...coupleProps} isOpen={true} parents={twoParents} />)
|
||||
await openAdvanced()
|
||||
expect(screen.getByTestId('parent-select')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows all parents in select options', async () => {
|
||||
render(<PlannedDayModal {...defaultProps} isOpen={true} parents={[{ id: 'p1', name: 'Alice' }, { id: 'p2', name: 'Bob' }]} />)
|
||||
render(<PlannedDayModal {...coupleProps} isOpen={true} parents={[{ id: 'p1', name: 'Alice' }, { id: 'p2', name: 'Bob' }]} />)
|
||||
await openAdvanced()
|
||||
const select = screen.getByTestId('parent-select') as HTMLSelectElement
|
||||
expect(select.options).toHaveLength(2)
|
||||
@@ -304,28 +382,28 @@ describe('PlannedDayModal', () => {
|
||||
})
|
||||
|
||||
it('disables parent select when isSaving', async () => {
|
||||
const { rerender } = render(<PlannedDayModal {...defaultProps} isOpen={true} parents={twoParents} />)
|
||||
const { rerender } = render(<PlannedDayModal {...coupleProps} isOpen={true} parents={twoParents} />)
|
||||
await openAdvanced()
|
||||
rerender(<PlannedDayModal {...defaultProps} isOpen={true} isSaving={true} parents={twoParents} />)
|
||||
rerender(<PlannedDayModal {...coupleProps} isOpen={true} isSaving={true} parents={twoParents} />)
|
||||
expect(screen.getByTestId('parent-select')).toBeDisabled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('dubbeldag checkbox (inside Advanced)', () => {
|
||||
it('renders dubbeldag checkbox', async () => {
|
||||
render(<PlannedDayModal {...defaultProps} isOpen={true} />)
|
||||
render(<PlannedDayModal {...coupleProps} isOpen={true} />)
|
||||
await openAdvanced()
|
||||
expect(screen.getByTestId('dubbeldag-checkbox')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('defaults to unchecked', async () => {
|
||||
render(<PlannedDayModal {...defaultProps} isOpen={true} />)
|
||||
render(<PlannedDayModal {...coupleProps} isOpen={true} />)
|
||||
await openAdvanced()
|
||||
expect(screen.getByTestId('dubbeldag-checkbox')).not.toBeChecked()
|
||||
})
|
||||
|
||||
it('can be checked for non-vacation day types', async () => {
|
||||
render(<PlannedDayModal {...defaultProps} isOpen={true} />)
|
||||
render(<PlannedDayModal {...coupleProps} isOpen={true} />)
|
||||
await openAdvanced()
|
||||
await userEvent.selectOptions(screen.getByTestId('day-type-select'), 'sjukpenning')
|
||||
await userEvent.click(screen.getByTestId('dubbeldag-checkbox'))
|
||||
@@ -333,26 +411,26 @@ describe('PlannedDayModal', () => {
|
||||
})
|
||||
|
||||
it('is disabled for vacation day type', async () => {
|
||||
render(<PlannedDayModal {...defaultProps} isOpen={true} />)
|
||||
render(<PlannedDayModal {...coupleProps} isOpen={true} />)
|
||||
await openAdvanced()
|
||||
expect(screen.getByTestId('dubbeldag-checkbox')).toBeDisabled()
|
||||
})
|
||||
|
||||
it('shows hint when dayType is vacation', async () => {
|
||||
render(<PlannedDayModal {...defaultProps} isOpen={true} />)
|
||||
render(<PlannedDayModal {...coupleProps} isOpen={true} />)
|
||||
await openAdvanced()
|
||||
expect(screen.getByText(/Dubbeldagar gäller inte för semester/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('hides hint when dayType is not vacation', async () => {
|
||||
render(<PlannedDayModal {...defaultProps} isOpen={true} />)
|
||||
render(<PlannedDayModal {...coupleProps} isOpen={true} />)
|
||||
await openAdvanced()
|
||||
await userEvent.selectOptions(screen.getByTestId('day-type-select'), 'sjukpenning')
|
||||
expect(screen.queryByText(/Dubbeldagar gäller inte för semester/)).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('unchecks dubbeldag when changing to vacation type', async () => {
|
||||
render(<PlannedDayModal {...defaultProps} isOpen={true} />)
|
||||
render(<PlannedDayModal {...coupleProps} isOpen={true} />)
|
||||
await openAdvanced()
|
||||
await userEvent.selectOptions(screen.getByTestId('day-type-select'), 'sjukpenning')
|
||||
await userEvent.click(screen.getByTestId('dubbeldag-checkbox'))
|
||||
@@ -365,7 +443,7 @@ describe('PlannedDayModal', () => {
|
||||
describe('save functionality (Advanced)', () => {
|
||||
it('calls onSavePinned with correct payload when save is clicked', async () => {
|
||||
mockOnSavePinned.mockResolvedValue(undefined)
|
||||
render(<PlannedDayModal {...defaultProps} isOpen={true} />)
|
||||
render(<PlannedDayModal {...coupleProps} isOpen={true} />)
|
||||
await openAdvanced()
|
||||
await userEvent.click(screen.getByTestId('save-button'))
|
||||
await waitFor(() =>
|
||||
@@ -375,7 +453,7 @@ describe('PlannedDayModal', () => {
|
||||
|
||||
it('calls onSavePinned with selected day type', async () => {
|
||||
mockOnSavePinned.mockResolvedValue(undefined)
|
||||
render(<PlannedDayModal {...defaultProps} isOpen={true} />)
|
||||
render(<PlannedDayModal {...coupleProps} isOpen={true} />)
|
||||
await openAdvanced()
|
||||
await userEvent.selectOptions(screen.getByTestId('day-type-select'), 'sjukpenning')
|
||||
await userEvent.click(screen.getByTestId('save-button'))
|
||||
@@ -386,7 +464,7 @@ describe('PlannedDayModal', () => {
|
||||
|
||||
it('calls onSavePinned with the selectedParentId prop', async () => {
|
||||
mockOnSavePinned.mockResolvedValue(undefined)
|
||||
render(<PlannedDayModal {...defaultProps} isOpen={true} parents={twoParents} selectedParentId="p2" />)
|
||||
render(<PlannedDayModal {...coupleProps} isOpen={true} parents={twoParents} selectedParentId="p2" />)
|
||||
await openAdvanced()
|
||||
await userEvent.click(screen.getByTestId('save-button'))
|
||||
await waitFor(() =>
|
||||
@@ -396,7 +474,7 @@ describe('PlannedDayModal', () => {
|
||||
|
||||
it('calls onSavePinned with isDubbeldag flag', async () => {
|
||||
mockOnSavePinned.mockResolvedValue(undefined)
|
||||
render(<PlannedDayModal {...defaultProps} isOpen={true} />)
|
||||
render(<PlannedDayModal {...coupleProps} isOpen={true} />)
|
||||
await openAdvanced()
|
||||
await userEvent.selectOptions(screen.getByTestId('day-type-select'), 'lagsta')
|
||||
await userEvent.click(screen.getByTestId('dubbeldag-checkbox'))
|
||||
@@ -437,7 +515,7 @@ describe('PlannedDayModal', () => {
|
||||
|
||||
it('displays local error after a failed pinned save', async () => {
|
||||
mockOnSavePinned.mockRejectedValue(new Error('Pinned save failed'))
|
||||
render(<PlannedDayModal {...defaultProps} isOpen={true} />)
|
||||
render(<PlannedDayModal {...coupleProps} isOpen={true} />)
|
||||
await openAdvanced()
|
||||
await userEvent.click(screen.getByTestId('save-button'))
|
||||
await waitFor(() => expect(screen.getByRole('alert')).toHaveTextContent('Pinned save failed'))
|
||||
@@ -617,7 +695,7 @@ describe('PlannedDayModal', () => {
|
||||
})
|
||||
|
||||
it('does not show a pinned notice for a brand-new day', async () => {
|
||||
render(<PlannedDayModal {...defaultProps} isOpen={true} />)
|
||||
render(<PlannedDayModal {...coupleProps} isOpen={true} />)
|
||||
await openAdvanced()
|
||||
expect(screen.queryByTestId('pinned-notice')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { ReactNode, useEffect, useState } from 'react'
|
||||
import type { PlannedLeaveDay } from '@/types'
|
||||
import { useTranslation } from '@/i18n'
|
||||
import { Icon } from '@/components/common/Icon'
|
||||
import './planned-day-modal.css'
|
||||
|
||||
/** The default path — todo.md #10/#11: the planner decides which day type
|
||||
@@ -18,11 +19,22 @@ export interface PlannedDayModalSaveWishPayload {
|
||||
* an exact day type for an exact parent, writing `planned_leave_day` rows
|
||||
* directly. */
|
||||
export interface PlannedDayModalSavePinnedPayload {
|
||||
dayType: 'sjukpenning' | 'lagsta' | 'vacation'
|
||||
dayType: PinnedDayType
|
||||
parentId: string
|
||||
isDubbeldag: boolean
|
||||
}
|
||||
|
||||
type PinnedDayType = 'sjukpenning' | 'lagsta' | 'vacation'
|
||||
|
||||
/** What the type select can hold: a concrete type to pin, or — for a solo
|
||||
* household, where the select is the only question left — `'auto'`, the
|
||||
* planner-decides default. */
|
||||
type DayTypeChoice = PinnedDayType | 'auto'
|
||||
|
||||
function isPinnedDayType(value: string): value is PinnedDayType {
|
||||
return value === 'sjukpenning' || value === 'lagsta' || value === 'vacation'
|
||||
}
|
||||
|
||||
/** One day the user removed by hand, ready to be offered back. */
|
||||
export interface PlannedDayExclusionEntry {
|
||||
id: string
|
||||
@@ -83,8 +95,11 @@ export function PlannedDayModal({
|
||||
// Editing an existing pinned day always reopens the advanced/pinned path —
|
||||
// it must never be silently reinterpreted as a new planner-funded wish.
|
||||
const forcedAdvanced = !isRangeMode && !!existingDay
|
||||
// A solo household has nobody to choose between, so the "Advanced"
|
||||
// disclosure would hide exactly one control. The type select is shown
|
||||
// directly instead, with the planner-decides choice as its default.
|
||||
const soloDirect = !hasMultipleParents && !forcedAdvanced
|
||||
const [isAdvancedOpen, setIsAdvancedOpen] = useState(forcedAdvanced)
|
||||
const advancedOpen = forcedAdvanced || isAdvancedOpen
|
||||
|
||||
// --- Default ("planner decides") path state -----------------------------
|
||||
const [parentMode, setParentMode] = useState<'either' | 'both' | 'specific'>(
|
||||
@@ -94,9 +109,15 @@ export function PlannedDayModal({
|
||||
const [wantsDubbeldag, setWantsDubbeldag] = useState(false)
|
||||
|
||||
// --- Advanced ("pin exactly") path state ---------------------------------
|
||||
const [dayType, setDayType] = useState<'sjukpenning' | 'lagsta' | 'vacation'>('vacation')
|
||||
const [dayType, setDayType] = useState<DayTypeChoice>(soloDirect ? 'auto' : 'vacation')
|
||||
const [isDubbeldag, setIsDubbeldag] = useState(false)
|
||||
|
||||
// Whether saving pins an exact type (the escape hatch) or hands the dates
|
||||
// to the planner as a wish. Couples opt in through the disclosure; a solo
|
||||
// household opts in by picking a concrete type in the select.
|
||||
const pinnedPath = forcedAdvanced || (soloDirect ? dayType !== 'auto' : isAdvancedOpen)
|
||||
const showPinnedControls = forcedAdvanced || soloDirect || isAdvancedOpen
|
||||
|
||||
const [localError, setLocalError] = useState<string | null>(null)
|
||||
const [isConfirmingDelete, setIsConfirmingDelete] = useState(false)
|
||||
|
||||
@@ -115,11 +136,13 @@ export function PlannedDayModal({
|
||||
}, [isOpen, date, isRangeMode])
|
||||
|
||||
useEffect(() => {
|
||||
if (existingDay) {
|
||||
setDayType(existingDay.dayType as 'sjukpenning' | 'lagsta' | 'vacation')
|
||||
// A range is never an edit of its first date's day, so that day's type
|
||||
// must not leak into the select as if it were being edited.
|
||||
if (existingDay && !isRangeMode) {
|
||||
setDayType(isPinnedDayType(existingDay.dayType) ? existingDay.dayType : 'vacation')
|
||||
setIsDubbeldag(existingDay.isDubbeldag)
|
||||
} else {
|
||||
setDayType(defaultDayType ?? 'vacation')
|
||||
setDayType(soloDirect ? 'auto' : (defaultDayType ?? 'vacation'))
|
||||
setIsDubbeldag(false)
|
||||
}
|
||||
// Deliberately NOT a separate "if dayType === 'vacation', clear
|
||||
@@ -131,7 +154,7 @@ export function PlannedDayModal({
|
||||
// vacation/dubbeldag rule is instead enforced right where the user
|
||||
// actually changes the type — see the advanced day-type `<select>`'s
|
||||
// `onChange` below.
|
||||
}, [isOpen, existingDay, defaultDayType])
|
||||
}, [isOpen, existingDay, defaultDayType, soloDirect, isRangeMode])
|
||||
|
||||
useEffect(() => {
|
||||
if (parentMode !== 'both') {
|
||||
@@ -142,7 +165,7 @@ export function PlannedDayModal({
|
||||
const handleSave = async () => {
|
||||
try {
|
||||
setLocalError(null)
|
||||
if (advancedOpen) {
|
||||
if (pinnedPath && dayType !== 'auto') {
|
||||
await onSavePinned({ dayType, parentId: selectedParentId, isDubbeldag })
|
||||
} else {
|
||||
await onSaveWish({
|
||||
@@ -217,7 +240,7 @@ export function PlannedDayModal({
|
||||
<div className="modal__header">
|
||||
<h2 data-testid="modal-title">{modalTitle}</h2>
|
||||
<button type="button" className="btn btn--icon" onClick={onClose} aria-label={t('plannedDayModal.closeAria')}>
|
||||
×
|
||||
<Icon name="close" size={20} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -297,10 +320,10 @@ export function PlannedDayModal({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!forcedAdvanced && (
|
||||
{!forcedAdvanced && hasMultipleParents && (
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn--ghost planned-day-modal__advanced-toggle"
|
||||
className="btn btn--link planned-day-modal__advanced-toggle"
|
||||
onClick={() => setIsAdvancedOpen((open) => !open)}
|
||||
disabled={isSaving}
|
||||
data-testid="advanced-toggle"
|
||||
@@ -310,7 +333,7 @@ export function PlannedDayModal({
|
||||
</button>
|
||||
)}
|
||||
|
||||
{advancedOpen && (
|
||||
{showPinnedControls && (
|
||||
<div className="planned-day-modal__advanced" data-testid="advanced-section">
|
||||
<div className="field">
|
||||
<label htmlFor="day-type" className="field__label">
|
||||
@@ -320,14 +343,16 @@ export function PlannedDayModal({
|
||||
id="day-type"
|
||||
value={dayType}
|
||||
onChange={(e) => {
|
||||
const value = e.target.value as 'sjukpenning' | 'lagsta' | 'vacation'
|
||||
setDayType(value)
|
||||
if (value === 'vacation') setIsDubbeldag(false)
|
||||
const value = e.target.value
|
||||
const next: DayTypeChoice = isPinnedDayType(value) ? value : 'auto'
|
||||
setDayType(next)
|
||||
if (next === 'vacation' || next === 'auto') setIsDubbeldag(false)
|
||||
}}
|
||||
className="field__control"
|
||||
disabled={isSaving}
|
||||
data-testid="day-type-select"
|
||||
>
|
||||
{soloDirect && <option value="auto">{t('plannedDayModal.autoOption')}</option>}
|
||||
<option value="vacation">{t('plannedDayModal.vacationOption')}</option>
|
||||
<option value="sjukpenning">{t('plannedDayModal.sjukpenningOption')}</option>
|
||||
<option value="lagsta">{t('plannedDayModal.lagstaOption')}</option>
|
||||
@@ -356,27 +381,30 @@ export function PlannedDayModal({
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="field">
|
||||
<label className="planned-day-modal__checkbox-label">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={isDubbeldag}
|
||||
onChange={(e) => setIsDubbeldag(e.target.checked)}
|
||||
disabled={isSaving || dayType === 'vacation'}
|
||||
data-testid="dubbeldag-checkbox"
|
||||
/>
|
||||
{' '}{t('plannedDayModal.doubleDayLabel')}
|
||||
</label>
|
||||
{dayType === 'vacation' && (
|
||||
<p className="planned-day-modal__hint">
|
||||
{t('plannedDayModal.doubleDayHint')}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
{dayType !== 'auto' && (
|
||||
<div className="field">
|
||||
<label className="planned-day-modal__checkbox-label">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={isDubbeldag}
|
||||
onChange={(e) => setIsDubbeldag(e.target.checked)}
|
||||
disabled={isSaving || dayType === 'vacation'}
|
||||
data-testid="dubbeldag-checkbox"
|
||||
/>
|
||||
{' '}{t('plannedDayModal.doubleDayLabel')}
|
||||
</label>
|
||||
{dayType === 'vacation' && (
|
||||
<p className="planned-day-modal__hint">
|
||||
{t('plannedDayModal.doubleDayHint')}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{existingDay?.isPinned && (
|
||||
<p className="planned-day-modal__pinned-notice" data-testid="pinned-notice">
|
||||
📌 {t('plannedDayModal.pinnedNotice')}
|
||||
<Icon name="pin" size={16} />
|
||||
<span>{t('plannedDayModal.pinnedNotice')}</span>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
/* Planned-day modal — the modal shell, fields, buttons and the inline
|
||||
* delete confirmation all come from primitives.css; this file only holds
|
||||
* what is specific to planning a day. */
|
||||
.planned-day-modal__date {
|
||||
font-size: var(--fs-sm);
|
||||
color: var(--c-text-muted);
|
||||
@@ -9,8 +12,10 @@
|
||||
display: flex;
|
||||
align-items: center;
|
||||
font-size: var(--fs-sm);
|
||||
font-weight: 400;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.planned-day-modal__checkbox-label input[type='checkbox'] {
|
||||
@@ -24,16 +29,17 @@
|
||||
|
||||
.planned-day-modal__hint {
|
||||
font-size: var(--fs-xs);
|
||||
color: var(--c-text-faint);
|
||||
margin-top: var(--sp-2);
|
||||
margin-left: var(--sp-6);
|
||||
font-style: italic;
|
||||
color: var(--c-text-muted);
|
||||
margin: var(--sp-1) 0 0 var(--sp-5);
|
||||
}
|
||||
|
||||
.planned-day-modal__pinned-notice {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--sp-2);
|
||||
font-size: var(--fs-xs);
|
||||
color: var(--c-text-muted);
|
||||
margin: calc(var(--sp-2) * -1) 0 var(--sp-4) 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* "You removed this day" + the way back. Wraps and sizes itself by content
|
||||
@@ -44,7 +50,7 @@
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: var(--sp-2);
|
||||
font-size: var(--fs-xs);
|
||||
font-size: var(--fs-sm);
|
||||
color: var(--c-text-muted);
|
||||
margin-bottom: var(--sp-4);
|
||||
}
|
||||
@@ -54,29 +60,17 @@
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.planned-day-modal__override-switch {
|
||||
display: flex;
|
||||
gap: var(--sp-4);
|
||||
margin-top: var(--sp-2);
|
||||
}
|
||||
|
||||
.planned-day-modal__override-switch .planned-day-modal__checkbox-label {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
/* The couple-mode "Advanced" disclosure reads as a plain link that may wrap
|
||||
* — `.btn--link` gives it the underline; this only lets the label break. */
|
||||
.planned-day-modal__advanced-toggle {
|
||||
align-self: flex-start;
|
||||
padding-left: 0;
|
||||
white-space: normal;
|
||||
text-align: left;
|
||||
font-size: var(--fs-sm);
|
||||
}
|
||||
|
||||
.planned-day-modal__advanced {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--sp-4);
|
||||
padding-top: var(--sp-2);
|
||||
border-top: 1px solid var(--c-border);
|
||||
}
|
||||
|
||||
/* Footer, buttons and the inline delete-confirmation row are shared with
|
||||
* the other modals via .modal__footer / .btn* / .modal__delete-confirm* in
|
||||
* src/styles/primitives.css — see that file for the button formula. */
|
||||
|
||||
@@ -4,14 +4,6 @@ export const calendar = {
|
||||
'calendar.selectMonthAria': 'Select month',
|
||||
'calendar.selectYearAria': 'Select year',
|
||||
'calendar.deleteError': 'Failed to delete the planned day',
|
||||
'calendar.legendDoubleDay': 'Double day',
|
||||
'calendar.legendIncomeBased': 'Income-based rate (sjukpenningnivå)',
|
||||
'calendar.legendKlamdag': 'Bridge day',
|
||||
'calendar.legendMinimumRate': 'Minimum rate (lägstanivå)',
|
||||
'calendar.legendOverride': 'Exception (working/day off)',
|
||||
'calendar.legendRedDay': 'R = public holiday',
|
||||
'calendar.legendTitle': 'Legend',
|
||||
'calendar.legendVacation': 'Vacation',
|
||||
'calendar.loading': 'Loading calendar...',
|
||||
'calendar.nextMonthAria': 'Next month',
|
||||
'calendar.overrideError': 'Failed to update the exception',
|
||||
@@ -30,7 +22,6 @@ export const calendar = {
|
||||
'calendar.weekdayTue': 'Tue',
|
||||
'calendar.weekdayWed': 'Wed',
|
||||
'calendar.you': 'You',
|
||||
'calendarDay.ariaWithEvents': '{date} with {count} event(s)',
|
||||
'calendarDay.excludedAria': 'removed day — the planner skips it',
|
||||
'calendarDay.klamdagAria': 'bridge day',
|
||||
'calendarDay.overrideFreeAria': 'marked as a day off (exception)',
|
||||
@@ -69,6 +60,7 @@ export const calendar = {
|
||||
'plannedDayModal.savingButton': 'Saving…',
|
||||
'plannedDayModal.sjukpenningOption': 'Sjukpenningnivå (income-based, 390-day tier)',
|
||||
'plannedDayModal.vacationOption': 'Vacation',
|
||||
'plannedDayModal.autoOption': 'Let the planner choose',
|
||||
'redDaysList.effectiveFree': 'Off',
|
||||
'redDaysList.effectiveWorking': 'Working day',
|
||||
'redDaysList.emptyYear': 'No public holidays or exceptions this year.',
|
||||
@@ -101,9 +93,30 @@ export const calendar = {
|
||||
'calendar.undoError': 'Failed to undo the change',
|
||||
'calendar.dismissToastAria': 'Dismiss the message',
|
||||
|
||||
// The "?" beside the month title — replaces the legend that used to sit
|
||||
// under the grid. One line per mark the calendar draws.
|
||||
'calendar.helpAria': 'What the calendar marks mean',
|
||||
'calendar.helpTitle': 'Days',
|
||||
'calendar.helpBandsTitle': 'Periods',
|
||||
'calendar.helpRedDay': 'Public holiday (R) — a red day or a Sunday',
|
||||
'calendar.helpKlamdag': 'Bridge day (K)',
|
||||
'calendar.helpSaturday': 'Saturday',
|
||||
'calendar.helpToday': 'Today',
|
||||
'calendar.helpOverride': 'Exception — marked as a working day or a day off',
|
||||
'calendar.helpExcluded': 'Removed day — the planner skips it',
|
||||
'calendar.helpVacation': 'Vacation',
|
||||
'calendar.helpSjukpenning': 'Income-based rate (sjukpenningnivå)',
|
||||
'calendar.helpLagsta': 'Minimum rate (lägstanivå)',
|
||||
'calendar.helpSelf': 'Your leave',
|
||||
'calendar.helpPartner': "Your partner's leave",
|
||||
'calendar.helpDubbeldag': 'Double day — both parents on the same day',
|
||||
'calendar.helpPinned': 'Pinned by you — the planner leaves the day alone',
|
||||
|
||||
'calendar.railHeading': 'Add leave',
|
||||
'calendar.railHint': 'Click to add — the calendar jumps to wherever it fits best.',
|
||||
'calendar.presetAddAria': 'Add {name}',
|
||||
'calendar.presetChipsLabel': 'Add leave',
|
||||
'calendar.presetWeeksShort': '{count} wk',
|
||||
|
||||
'calendar.periodPopoverTitle': 'Edit period',
|
||||
'calendar.periodAdHocTitle': 'Period',
|
||||
@@ -135,6 +148,7 @@ export const calendar = {
|
||||
'calendar.periodDeleteAdHocButton': 'Delete these days',
|
||||
'calendar.periodCancelButton': 'Cancel',
|
||||
'calendar.periodCloseAria': 'Close',
|
||||
'calendar.periodDeleteMenuButton': 'Remove…',
|
||||
|
||||
// todo.md #9 — three-way remove: this day / this period (this year) / this
|
||||
// period every year.
|
||||
|
||||
@@ -4,14 +4,6 @@ export const calendar = {
|
||||
'calendar.selectMonthAria': 'Välj månad',
|
||||
'calendar.selectYearAria': 'Välj år',
|
||||
'calendar.deleteError': 'Det gick inte att ta bort den planerade dagen',
|
||||
'calendar.legendDoubleDay': 'Dubbeldag',
|
||||
'calendar.legendIncomeBased': 'Sjukpenningnivå',
|
||||
'calendar.legendKlamdag': 'Klämdag',
|
||||
'calendar.legendMinimumRate': 'Lägstanivå',
|
||||
'calendar.legendOverride': 'Undantag (arbets-/ledig dag)',
|
||||
'calendar.legendRedDay': 'R = röd dag',
|
||||
'calendar.legendTitle': 'Teckenförklaring',
|
||||
'calendar.legendVacation': 'Semester',
|
||||
'calendar.loading': 'Laddar kalender...',
|
||||
'calendar.nextMonthAria': 'Nästa månad',
|
||||
'calendar.overrideError': 'Det gick inte att uppdatera undantaget',
|
||||
@@ -30,7 +22,6 @@ export const calendar = {
|
||||
'calendar.weekdayTue': 'Tis',
|
||||
'calendar.weekdayWed': 'Ons',
|
||||
'calendar.you': 'Du',
|
||||
'calendarDay.ariaWithEvents': '{date} med {count} händelse(r)',
|
||||
'calendarDay.excludedAria': 'borttagen dag – planeraren hoppar över den',
|
||||
'calendarDay.klamdagAria': 'klämdag',
|
||||
'calendarDay.overrideFreeAria': 'markerad som ledig dag (undantag)',
|
||||
@@ -69,6 +60,7 @@ export const calendar = {
|
||||
'plannedDayModal.savingButton': 'Sparar…',
|
||||
'plannedDayModal.sjukpenningOption': 'Sjukpenningnivå (390-nivån)',
|
||||
'plannedDayModal.vacationOption': 'Semester',
|
||||
'plannedDayModal.autoOption': 'Låt planeraren välja',
|
||||
'redDaysList.effectiveFree': 'Ledig',
|
||||
'redDaysList.effectiveWorking': 'Arbetsdag',
|
||||
'redDaysList.emptyYear': 'Inga röda dagar eller undantag detta år.',
|
||||
@@ -101,9 +93,30 @@ export const calendar = {
|
||||
'calendar.undoError': 'Det gick inte att ångra ändringen',
|
||||
'calendar.dismissToastAria': 'Stäng meddelandet',
|
||||
|
||||
// The "?" beside the month title — replaces the legend that used to sit
|
||||
// under the grid. One line per mark the calendar draws.
|
||||
'calendar.helpAria': 'Förklaring av kalenderns markeringar',
|
||||
'calendar.helpTitle': 'Dagar',
|
||||
'calendar.helpBandsTitle': 'Perioder',
|
||||
'calendar.helpRedDay': 'Röd dag (R) — helgdag eller söndag',
|
||||
'calendar.helpKlamdag': 'Klämdag (K)',
|
||||
'calendar.helpSaturday': 'Lördag',
|
||||
'calendar.helpToday': 'Idag',
|
||||
'calendar.helpOverride': 'Undantag — markerad som arbetsdag eller ledig dag',
|
||||
'calendar.helpExcluded': 'Borttagen dag — planeraren hoppar över den',
|
||||
'calendar.helpVacation': 'Semester',
|
||||
'calendar.helpSjukpenning': 'Sjukpenningnivå',
|
||||
'calendar.helpLagsta': 'Lägstanivå',
|
||||
'calendar.helpSelf': 'Din ledighet',
|
||||
'calendar.helpPartner': 'Partnerns ledighet',
|
||||
'calendar.helpDubbeldag': 'Dubbeldag — båda föräldrarna samma dag',
|
||||
'calendar.helpPinned': 'Låst av dig — planeraren ändrar inte dagen',
|
||||
|
||||
'calendar.railHeading': 'Lägg till ledighet',
|
||||
'calendar.railHint': 'Klicka för att lägga till — kalendern hoppar till där den passar bäst.',
|
||||
'calendar.presetAddAria': 'Lägg till {name}',
|
||||
'calendar.presetChipsLabel': 'Lägg till ledighet',
|
||||
'calendar.presetWeeksShort': '{count} v',
|
||||
|
||||
'calendar.periodPopoverTitle': 'Redigera period',
|
||||
'calendar.periodAdHocTitle': 'Period',
|
||||
@@ -136,6 +149,7 @@ export const calendar = {
|
||||
'calendar.periodDeleteAdHocButton': 'Ta bort dessa dagar',
|
||||
'calendar.periodCancelButton': 'Avbryt',
|
||||
'calendar.periodCloseAria': 'Stäng',
|
||||
'calendar.periodDeleteMenuButton': 'Ta bort…',
|
||||
|
||||
// todo.md #9 — three-way remove: this day / this period (this year) / this
|
||||
// period every year.
|
||||
|
||||
@@ -336,9 +336,8 @@ describe('Calendar', () => {
|
||||
// week" logic would add a 6th row of 7 disabled June cells that serves
|
||||
// no purpose.
|
||||
render(<Calendar />)
|
||||
const title = screen.getByRole('heading', { level: 2 })
|
||||
const yearSelect = title.querySelector('.calendar__year-select') as HTMLSelectElement
|
||||
const monthSelect = title.querySelector('.calendar__month-select') as HTMLSelectElement
|
||||
const yearSelect = screen.getByLabelText('Välj år') as HTMLSelectElement
|
||||
const monthSelect = screen.getByLabelText('Välj månad') as HTMLSelectElement
|
||||
fireEvent.change(yearSelect, { target: { value: '2026' } })
|
||||
fireEvent.change(monthSelect, { target: { value: '4' } })
|
||||
|
||||
@@ -374,10 +373,20 @@ describe('Calendar', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('displays current month title', () => {
|
||||
it('displays the month and year as real title text, capitalised', () => {
|
||||
render(<Calendar />)
|
||||
const title = screen.getByRole('heading', { level: 2 })
|
||||
expect(title.textContent).toMatch(/\d{4}/)
|
||||
expect(title.textContent).toMatch(
|
||||
/^(Januari|Februari|Mars|April|Maj|Juni|Juli|Augusti|September|Oktober|November|December) \d{4}$/,
|
||||
)
|
||||
// The title holds no controls: the month/year selects live beside it.
|
||||
expect(title.querySelector('select')).toBeNull()
|
||||
})
|
||||
|
||||
it('renders the help popover button in place of the old legend', () => {
|
||||
render(<Calendar />)
|
||||
expect(screen.getByTestId('calendar-help-button')).toBeInTheDocument()
|
||||
expect(document.querySelector('.calendar__legend')).toBeNull()
|
||||
})
|
||||
|
||||
it('handles next/prev month navigation', () => {
|
||||
@@ -405,9 +414,8 @@ describe('Calendar', () => {
|
||||
|
||||
it('jumps forward/backward via month and year dropdowns', () => {
|
||||
render(<Calendar />)
|
||||
const title = screen.getByRole('heading', { level: 2 })
|
||||
const monthSelect = title.querySelector('.calendar__month-select') as HTMLSelectElement
|
||||
const yearSelect = title.querySelector('.calendar__year-select') as HTMLSelectElement
|
||||
const monthSelect = screen.getByLabelText('Välj månad') as HTMLSelectElement
|
||||
const yearSelect = screen.getByLabelText('Välj år') as HTMLSelectElement
|
||||
|
||||
fireEvent.change(monthSelect, { target: { value: '5' } })
|
||||
expect(monthSelect.value).toBe('5')
|
||||
@@ -418,9 +426,8 @@ describe('Calendar', () => {
|
||||
|
||||
it('Today button jumps back to the current month/year after navigating away', () => {
|
||||
render(<Calendar />)
|
||||
const title = screen.getByRole('heading', { level: 2 })
|
||||
const monthSelect = title.querySelector('.calendar__month-select') as HTMLSelectElement
|
||||
const yearSelect = title.querySelector('.calendar__year-select') as HTMLSelectElement
|
||||
const monthSelect = screen.getByLabelText('Välj månad') as HTMLSelectElement
|
||||
const yearSelect = screen.getByLabelText('Välj år') as HTMLSelectElement
|
||||
|
||||
fireEvent.change(monthSelect, { target: { value: '5' } })
|
||||
fireEvent.change(yearSelect, { target: { value: '2035' } })
|
||||
@@ -438,8 +445,7 @@ describe('Calendar', () => {
|
||||
makeCalendarDaysResult({ data: makeCalendarWindow({ minYear: 2026, maxYear: 2055 }) }),
|
||||
)
|
||||
render(<Calendar />)
|
||||
const title = screen.getByRole('heading', { level: 2 })
|
||||
const yearSelect = title.querySelector('.calendar__year-select') as HTMLSelectElement
|
||||
const yearSelect = screen.getByLabelText('Välj år') as HTMLSelectElement
|
||||
const options = Array.from(yearSelect.options).map((opt) => parseInt(opt.value))
|
||||
expect(Math.min(...options)).toBe(2026)
|
||||
expect(Math.max(...options)).toBe(2055)
|
||||
@@ -448,8 +454,7 @@ describe('Calendar', () => {
|
||||
it('always includes the current year even before the calendar window has loaded', () => {
|
||||
vi.mocked(useCalendarDays).mockReturnValue(makeCalendarDaysResult({ data: null, isLoading: true }))
|
||||
render(<Calendar />)
|
||||
const title = screen.getByRole('heading', { level: 2 })
|
||||
const yearSelect = title.querySelector('.calendar__year-select') as HTMLSelectElement
|
||||
const yearSelect = screen.getByLabelText('Välj år') as HTMLSelectElement
|
||||
const options = Array.from(yearSelect.options).map((opt) => parseInt(opt.value))
|
||||
expect(options).toContain(new Date().getFullYear())
|
||||
})
|
||||
@@ -462,10 +467,8 @@ describe('Calendar', () => {
|
||||
])
|
||||
|
||||
render(<Calendar />)
|
||||
|
||||
const title = screen.getByRole('heading', { level: 2 })
|
||||
const yearSelect = title.querySelector('.calendar__year-select') as HTMLSelectElement
|
||||
const monthSelect = title.querySelector('.calendar__month-select') as HTMLSelectElement
|
||||
const yearSelect = screen.getByLabelText('Välj år') as HTMLSelectElement
|
||||
const monthSelect = screen.getByLabelText('Välj månad') as HTMLSelectElement
|
||||
expect(yearSelect.value).toBe('2030')
|
||||
expect(monthSelect.value).toBe('6') // July is month index 6
|
||||
|
||||
@@ -485,9 +488,8 @@ describe('Calendar', () => {
|
||||
render(<Calendar />)
|
||||
|
||||
const today = new Date()
|
||||
const title = screen.getByRole('heading', { level: 2 })
|
||||
const yearSelect = title.querySelector('.calendar__year-select') as HTMLSelectElement
|
||||
const monthSelect = title.querySelector('.calendar__month-select') as HTMLSelectElement
|
||||
const yearSelect = screen.getByLabelText('Välj år') as HTMLSelectElement
|
||||
const monthSelect = screen.getByLabelText('Välj månad') as HTMLSelectElement
|
||||
expect(yearSelect.value).toBe(String(today.getFullYear()))
|
||||
expect(monthSelect.value).toBe(String(today.getMonth()))
|
||||
expect(screen.queryByTestId('modal')).not.toBeInTheDocument()
|
||||
@@ -499,9 +501,8 @@ describe('Calendar', () => {
|
||||
render(<Calendar />)
|
||||
|
||||
const today = new Date()
|
||||
const title = screen.getByRole('heading', { level: 2 })
|
||||
const yearSelect = title.querySelector('.calendar__year-select') as HTMLSelectElement
|
||||
const monthSelect = title.querySelector('.calendar__month-select') as HTMLSelectElement
|
||||
const yearSelect = screen.getByLabelText('Välj år') as HTMLSelectElement
|
||||
const monthSelect = screen.getByLabelText('Välj månad') as HTMLSelectElement
|
||||
expect(yearSelect.value).toBe(String(today.getFullYear()))
|
||||
expect(monthSelect.value).toBe(String(today.getMonth()))
|
||||
})
|
||||
@@ -686,9 +687,8 @@ describe('Calendar', () => {
|
||||
|
||||
it('a leading week\'s in-month days are draggable (regression: the week used to be gated by weekDates[0]\'s month, not the displayed month)', () => {
|
||||
render(<Calendar />)
|
||||
const title = screen.getByRole('heading', { level: 2 })
|
||||
const yearSelect = title.querySelector('.calendar__year-select') as HTMLSelectElement
|
||||
const monthSelect = title.querySelector('.calendar__month-select') as HTMLSelectElement
|
||||
const yearSelect = screen.getByLabelText('Välj år') as HTMLSelectElement
|
||||
const monthSelect = screen.getByLabelText('Välj månad') as HTMLSelectElement
|
||||
// March 2026 starts on a Sunday, so its first calendar row is a
|
||||
// leading week made up of six February days plus March 1st.
|
||||
fireEvent.change(yearSelect, { target: { value: '2026' } })
|
||||
@@ -1037,8 +1037,7 @@ describe('Calendar', () => {
|
||||
fireEvent.click(screen.getByTestId('preset-click'))
|
||||
|
||||
await waitFor(() => {
|
||||
const title = screen.getByRole('heading', { level: 2 })
|
||||
const yearSelect = title.querySelector('.calendar__year-select') as HTMLSelectElement
|
||||
const yearSelect = screen.getByLabelText('Välj år') as HTMLSelectElement
|
||||
expect(yearSelect.value).toBe('2030')
|
||||
})
|
||||
})
|
||||
@@ -1448,9 +1447,11 @@ describe('Calendar', () => {
|
||||
<Calendar />
|
||||
</LanguageProvider>,
|
||||
)
|
||||
const title = screen.getByRole('heading', { level: 2 })
|
||||
const monthSelect = title.querySelector('.calendar__month-select') as HTMLSelectElement
|
||||
expect(monthSelect.getAttribute('aria-label')).toBe('Select month')
|
||||
expect(screen.getByLabelText('Select month')).toBeInTheDocument()
|
||||
// The title is real text now, in the English month name.
|
||||
expect(screen.getByRole('heading', { level: 2 }).textContent).toMatch(
|
||||
/^(January|February|March|April|May|June|July|August|September|October|November|December) \d{4}$/,
|
||||
)
|
||||
} finally {
|
||||
Object.defineProperty(window.navigator, 'language', { value: 'sv-SE', configurable: true })
|
||||
vi.unstubAllGlobals()
|
||||
@@ -1624,12 +1625,12 @@ describe('Calendar', () => {
|
||||
render(<Calendar />)
|
||||
await waitFor(() => expect(sync).toHaveBeenCalledTimes(1))
|
||||
|
||||
const yearBefore = (screen.getByRole('heading', { level: 2 }).querySelector('.calendar__year-select') as HTMLSelectElement).value
|
||||
const yearBefore = (screen.getByLabelText('Välj år') as HTMLSelectElement).value
|
||||
|
||||
fireEvent.click(screen.getByTestId('preset-click'))
|
||||
|
||||
await waitFor(() => expect(screen.getByTestId('calendar-toast')).toHaveTextContent('sync failed'))
|
||||
const yearAfter = (screen.getByRole('heading', { level: 2 }).querySelector('.calendar__year-select') as HTMLSelectElement).value
|
||||
const yearAfter = (screen.getByLabelText('Välj år') as HTMLSelectElement).value
|
||||
expect(yearAfter).toBe(yearBefore)
|
||||
})
|
||||
|
||||
|
||||
+79
-104
@@ -30,7 +30,8 @@ import { CalendarWeek, computeWeekLaneCount, type BandClickInfo } from '@/compon
|
||||
import { PlannedDayModal } from '@/components/modals/PlannedDayModal'
|
||||
import { PeriodPopover, type PeriodPopoverSavePayload, type PeriodDeleteScope } from '@/components/calendar/PeriodPopover'
|
||||
import { PresetRail, type SeasonPreset } from '@/components/calendar/PresetRail'
|
||||
import { getColorForType, getTypeModifier } from '@/utils/leaveTypes'
|
||||
import { CalendarHelpPopover } from '@/components/calendar/CalendarHelpPopover'
|
||||
import { Icon } from '@/components/common/Icon'
|
||||
import { resolveWishName, resolveWishNameFromData } from '@/utils/wishName'
|
||||
import { datesInRange, formatDateForDisplay, formatDateLocalISO, isValidDate, parseInputDate } from '@/utils/dates'
|
||||
import { renderRejection, type Translate } from '@/utils/rejectionMessages'
|
||||
@@ -1076,41 +1077,58 @@ export function Calendar(): ReactNode {
|
||||
? wishesHook.data.find((w) => w.id === activeBand.wishId) ?? null
|
||||
: null
|
||||
|
||||
// The month title is real text ("September 2026"); the two selects beside
|
||||
// it are the way to jump further than one month at a time.
|
||||
const intlLocale = locale === 'sv' ? 'sv-SE' : 'en-US'
|
||||
const monthName = (m: number): string => {
|
||||
const raw = new Date(year, m, 1).toLocaleDateString(intlLocale, { month: 'long' })
|
||||
return raw.charAt(0).toLocaleUpperCase(intlLocale) + raw.slice(1)
|
||||
}
|
||||
|
||||
return (
|
||||
<Layout>
|
||||
<div data-testid="calendar" className="calendar">
|
||||
<div className="calendar__header">
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn--icon"
|
||||
onClick={handlePrevMonth}
|
||||
aria-label={t('calendar.prevMonthAria')}
|
||||
>
|
||||
←
|
||||
</button>
|
||||
<h2 className="calendar__title">
|
||||
<div className="calendar__nav">
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn--ghost calendar__today-button"
|
||||
className="btn btn--icon"
|
||||
onClick={handlePrevMonth}
|
||||
aria-label={t('calendar.prevMonthAria')}
|
||||
>
|
||||
<Icon name="chevron-left" size={20} />
|
||||
</button>
|
||||
<h2 className="calendar__title">
|
||||
{monthName(month)} {year}
|
||||
</h2>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn--icon"
|
||||
onClick={handleNextMonth}
|
||||
aria-label={t('calendar.nextMonthAria')}
|
||||
>
|
||||
<Icon name="chevron-right" size={20} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn--ghost btn--sm calendar__today-button"
|
||||
onClick={() => setCurrentMonth(new Date())}
|
||||
>
|
||||
{t('calendar.today')}
|
||||
</button>
|
||||
</div>
|
||||
<div className="calendar__jump">
|
||||
<select
|
||||
value={month}
|
||||
onChange={handleMonthChange}
|
||||
className="calendar__month-select"
|
||||
aria-label={t('calendar.selectMonthAria')}
|
||||
>
|
||||
{[0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11].map((m) => {
|
||||
const date = new Date(year, m, 1)
|
||||
const monthName = date.toLocaleDateString(locale === 'sv' ? 'sv-SE' : 'en-US', { month: 'long' })
|
||||
return (
|
||||
<option key={m} value={m}>
|
||||
{monthName}
|
||||
</option>
|
||||
)
|
||||
})}
|
||||
{[0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11].map((m) => (
|
||||
<option key={m} value={m}>
|
||||
{monthName(m)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<select
|
||||
value={year}
|
||||
@@ -1124,15 +1142,8 @@ export function Calendar(): ReactNode {
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</h2>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn--icon"
|
||||
onClick={handleNextMonth}
|
||||
aria-label={t('calendar.nextMonthAria')}
|
||||
>
|
||||
→
|
||||
</button>
|
||||
<CalendarHelpPopover />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{(syncHook.isSyncing || isCreatingPeriod) && (
|
||||
@@ -1147,7 +1158,7 @@ export function Calendar(): ReactNode {
|
||||
{toast.undo && (
|
||||
<button
|
||||
type="button"
|
||||
className="calendar__toast-undo"
|
||||
className="btn btn--link calendar__toast-undo"
|
||||
onClick={() => void handleUndoClick()}
|
||||
disabled={isUndoing}
|
||||
data-testid="calendar-toast-undo"
|
||||
@@ -1157,92 +1168,56 @@ export function Calendar(): ReactNode {
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className="calendar__toast-dismiss"
|
||||
className="btn btn--icon btn--sm calendar__toast-dismiss"
|
||||
aria-label={t('calendar.dismissToastAria')}
|
||||
onClick={() => setToast(null)}
|
||||
data-testid="calendar-toast-dismiss"
|
||||
>
|
||||
×
|
||||
<Icon name="close" size={16} />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="calendar__layout">
|
||||
<div className="calendar__main">
|
||||
<div className={`calendar__grid${showWeekNumbers ? ' calendar__grid--with-week-numbers' : ''}`}>
|
||||
{showWeekNumbers && <div className="calendar__week-number-header" />}
|
||||
{WEEKDAY_KEYS.map((key) => (
|
||||
<div key={key} className="calendar__weekday">
|
||||
{t(key)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="calendar__main">
|
||||
<PresetRail isBusy={isCreatingPeriod || syncHook.isSyncing} onSelectPreset={handleSelectPreset} />
|
||||
|
||||
{/* Each week is its own small CSS Grid (see CalendarWeek.tsx/
|
||||
calendar.css) so a multi-day band can span several day columns as
|
||||
one element — that only works if each week is a separate grid,
|
||||
not a flat row of the header grid above. */}
|
||||
<div className="calendar__weeks">
|
||||
{weeks.map((week, weekIdx) => (
|
||||
<CalendarWeek
|
||||
key={weekIdx}
|
||||
weekDates={week}
|
||||
plannedDays={plannedDaysHook.data ?? []}
|
||||
selectedDates={highlightedDates}
|
||||
parentName={parentName ?? t('calendar.you')}
|
||||
personalDataUuid={personalDataUuid ?? ''}
|
||||
parentNames={parentNamesByUuid}
|
||||
displayedYear={year}
|
||||
displayedMonth={month}
|
||||
showWeekNumbers={showWeekNumbers}
|
||||
onDayClick={handleDayClick}
|
||||
onPointerDown={handlePointerDown}
|
||||
onPointerEnter={handlePointerEnter}
|
||||
calendarDaysByDate={calendarDaysByDate}
|
||||
excludedDates={excludedDates}
|
||||
onBandClick={handleBandClick}
|
||||
flashWishId={flashWishId}
|
||||
monthMaxLaneCount={monthMaxLaneCount}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="calendar__legend">
|
||||
<h3>{t('calendar.legendTitle')}</h3>
|
||||
<div className="calendar__legend-items">
|
||||
<div className="calendar__legend-item">
|
||||
<span className="calendar__legend-swatch" style={{ backgroundColor: getColorForType('vacation') }} />
|
||||
<span>{t('calendar.legendVacation')}</span>
|
||||
</div>
|
||||
<div className="calendar__legend-item">
|
||||
<span className="calendar__legend-swatch" style={{ backgroundColor: getColorForType('sjukpenning') }} />
|
||||
<span>{t('calendar.legendIncomeBased')}</span>
|
||||
</div>
|
||||
<div className="calendar__legend-item">
|
||||
<span className={`calendar__legend-swatch calendar__legend-swatch--${getTypeModifier('lagsta')}`} style={{ backgroundColor: getColorForType('lagsta') }} />
|
||||
<span>{t('calendar.legendMinimumRate')}</span>
|
||||
</div>
|
||||
<div className="calendar__legend-item">
|
||||
<span className="calendar__legend-swatch calendar__legend-swatch--red-day" />
|
||||
<span>{t('calendar.legendRedDay')}</span>
|
||||
</div>
|
||||
<div className="calendar__legend-item">
|
||||
<span className="calendar__legend-swatch calendar__legend-swatch--klamdag" />
|
||||
<span>{t('calendar.legendKlamdag')}</span>
|
||||
</div>
|
||||
<div className="calendar__legend-item">
|
||||
<span className="calendar__legend-swatch calendar__legend-swatch--override" />
|
||||
<span>{t('calendar.legendOverride')}</span>
|
||||
</div>
|
||||
<div className="calendar__legend-item">
|
||||
<span className="calendar__legend-swatch calendar__legend-swatch--dubbeldag">2×</span>
|
||||
<span>{t('calendar.legendDoubleDay')}</span>
|
||||
</div>
|
||||
<div className={`calendar__grid${showWeekNumbers ? ' calendar__grid--with-week-numbers' : ''}`}>
|
||||
{showWeekNumbers && <div className="calendar__week-number-header" />}
|
||||
{WEEKDAY_KEYS.map((key) => (
|
||||
<div key={key} className="calendar__weekday">
|
||||
{t(key)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<PresetRail isBusy={isCreatingPeriod || syncHook.isSyncing} onSelectPreset={handleSelectPreset} />
|
||||
{/* Each week is its own small CSS Grid (see CalendarWeek.tsx/
|
||||
calendar.css) so a multi-day band can span several day columns as
|
||||
one element — that only works if each week is a separate grid,
|
||||
not a flat row of the header grid above. */}
|
||||
<div className="calendar__weeks">
|
||||
{weeks.map((week, weekIdx) => (
|
||||
<CalendarWeek
|
||||
key={weekIdx}
|
||||
weekDates={week}
|
||||
plannedDays={plannedDaysHook.data ?? []}
|
||||
selectedDates={highlightedDates}
|
||||
parentName={parentName ?? t('calendar.you')}
|
||||
personalDataUuid={personalDataUuid ?? ''}
|
||||
parentNames={parentNamesByUuid}
|
||||
displayedYear={year}
|
||||
displayedMonth={month}
|
||||
showWeekNumbers={showWeekNumbers}
|
||||
onDayClick={handleDayClick}
|
||||
onPointerDown={handlePointerDown}
|
||||
onPointerEnter={handlePointerEnter}
|
||||
calendarDaysByDate={calendarDaysByDate}
|
||||
excludedDates={excludedDates}
|
||||
onBandClick={handleBandClick}
|
||||
flashWishId={flashWishId}
|
||||
monthMaxLaneCount={monthMaxLaneCount}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{firstSelectedDate && (
|
||||
|
||||
+380
-578
File diff suppressed because it is too large
Load Diff
@@ -147,6 +147,13 @@
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/* The footer stacks bottom-up here (column-reverse above), so the
|
||||
* revealed delete scopes sort FIRST to land at the bottom, directly under
|
||||
* the "Ta bort…" button that opened them. */
|
||||
.period-popover__delete-group {
|
||||
order: -10;
|
||||
}
|
||||
|
||||
/* Lists */
|
||||
.dashboard__break-item {
|
||||
flex-direction: column;
|
||||
@@ -160,56 +167,36 @@
|
||||
min-height: 200px;
|
||||
}
|
||||
|
||||
/* Calendar responsiveness */
|
||||
.calendar__grid {
|
||||
font-size: var(--fs-sm);
|
||||
/* Calendar — cells are ~48px wide at 390px, so the padding tightens and
|
||||
* the numeral keeps its 24px box flush to the top-left. */
|
||||
.calendar__weekday {
|
||||
padding: 0 var(--sp-1) var(--sp-2);
|
||||
}
|
||||
|
||||
.calendar__weekday,
|
||||
.calendar-day {
|
||||
padding: var(--sp-2);
|
||||
min-height: 64px;
|
||||
padding: var(--sp-1) var(--sp-1) calc(var(--sp-1) + var(--band-reserved-height, 0px));
|
||||
}
|
||||
|
||||
.calendar-day__number {
|
||||
font-size: var(--fs-sm);
|
||||
.calendar__week-number {
|
||||
padding-top: var(--sp-1);
|
||||
}
|
||||
|
||||
/* At 390px the month/year selects plus the two prev/next icon buttons are
|
||||
* tight against the header's title size; one step down keeps the whole
|
||||
* row inside the viewport. */
|
||||
.calendar__month-select,
|
||||
.calendar__year-select {
|
||||
font-size: var(--fs-lg);
|
||||
.calendar-band__label {
|
||||
padding: 0 var(--sp-1);
|
||||
}
|
||||
|
||||
/* The fixed-width preset rail sits beside the month grid on desktop; on
|
||||
* mobile the layout stacks and the rail goes full-width below the grid.
|
||||
* `align-items: stretch` overrides the base rule's `flex-start` — without
|
||||
* it a column-direction flex item is sized by its own shrink-to-fit
|
||||
* content width, which is what once let `.calendar__main` grow past the
|
||||
* viewport. */
|
||||
.calendar__layout {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.preset-rail {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* A horizontally-scrollable row of presets, not a stack — keeps the rail
|
||||
* from pushing the month grid far down the page on a short viewport. */
|
||||
.preset-rail__buttons {
|
||||
flex-direction: row;
|
||||
/* The preset chips scroll sideways in one row instead of wrapping, so the
|
||||
* grid is never pushed down the page. */
|
||||
.preset-chips__row {
|
||||
flex-wrap: nowrap;
|
||||
overflow-x: auto;
|
||||
scroll-snap-type: x mandatory;
|
||||
scrollbar-width: none;
|
||||
padding-bottom: var(--sp-1);
|
||||
}
|
||||
|
||||
.preset-rail__button {
|
||||
flex: 0 0 auto;
|
||||
min-width: 65vw;
|
||||
scroll-snap-align: start;
|
||||
.preset-chips__row::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Footer — same side gutter as .layout__main at this width. */
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { getColorForType, getInkForType, getLabelForType, getTypeModifier } from './leaveTypes'
|
||||
import { getColorForType, getInkForType, getLabelForType, getTypeKey, getTypeModifier } from './leaveTypes'
|
||||
import { translate } from '@/i18n'
|
||||
|
||||
describe('getColorForType', () => {
|
||||
@@ -32,6 +32,18 @@ describe('getInkForType', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('getTypeKey', () => {
|
||||
it('returns the bare type word for each known type', () => {
|
||||
expect(getTypeKey('vacation')).toBe('vacation')
|
||||
expect(getTypeKey('sjukpenning')).toBe('sjukpenning')
|
||||
expect(getTypeKey('lagsta')).toBe('lagsta')
|
||||
})
|
||||
|
||||
it('returns unknown for an unknown type', () => {
|
||||
expect(getTypeKey('something-else')).toBe('unknown')
|
||||
})
|
||||
})
|
||||
|
||||
describe('getTypeModifier', () => {
|
||||
it('returns a type-* modifier suffix for each known type', () => {
|
||||
expect(getTypeModifier('vacation')).toBe('type-vacation')
|
||||
|
||||
@@ -25,13 +25,24 @@ export function getInkForType(type: string): string {
|
||||
return isKnownType(type) ? `var(--c-leave-${type}-ink)` : 'var(--c-surface)'
|
||||
}
|
||||
|
||||
export type LeaveTypeKey = KnownType | 'unknown'
|
||||
|
||||
/**
|
||||
* BEM modifier suffix for a leave type, for callers that need a class hook
|
||||
* (lägsta's outline/hatch can't be expressed as a single inline colour):
|
||||
* `` `calendar-band--${getTypeModifier(type)}` ``.
|
||||
* The leave type as a bare BEM modifier word (`vacation` / `sjukpenning` /
|
||||
* `lagsta`, or `unknown`) — the calendar's bands and ticks use it directly:
|
||||
* `` `calendar-band--${getTypeKey(type)}` ``.
|
||||
*/
|
||||
export function getTypeKey(type: string): LeaveTypeKey {
|
||||
return isKnownType(type) ? type : 'unknown'
|
||||
}
|
||||
|
||||
/**
|
||||
* The older `type-*` prefixed modifier, still used by the dashboard's year
|
||||
* timeline (`year-timeline__band--type-lagsta`); the calendar has moved to
|
||||
* `getTypeKey`.
|
||||
*/
|
||||
export function getTypeModifier(type: string): string {
|
||||
return isKnownType(type) ? `type-${type}` : 'type-unknown'
|
||||
return `type-${getTypeKey(type)}`
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user