diff --git a/frontend/e2e/tests/drag-create-delete.spec.ts b/frontend/e2e/tests/drag-create-delete.spec.ts index 958ab83..95a3f7c 100644 --- a/frontend/e2e/tests/drag-create-delete.spec.ts +++ b/frontend/e2e/tests/drag-create-delete.spec.ts @@ -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() diff --git a/frontend/e2e/tests/red-days.spec.ts b/frontend/e2e/tests/red-days.spec.ts index 467662a..3dc1ddd 100644 --- a/frontend/e2e/tests/red-days.spec.ts +++ b/frontend/e2e/tests/red-days.spec.ts @@ -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 { 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) { + 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) }) }) diff --git a/frontend/src/components/calendar/CalendarHelpPopover.test.tsx b/frontend/src/components/calendar/CalendarHelpPopover.test.tsx new file mode 100644 index 0000000..e674c83 --- /dev/null +++ b/frontend/src/components/calendar/CalendarHelpPopover.test.tsx @@ -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() + 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() + 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() + 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() + 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() + 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( +
+ + +
, + ) + 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( +
+ + +
, + ) + fireEvent.pointerDown(screen.getByTestId('outside')) + fireEvent.click(screen.getByTestId('calendar-help-button')) + expect(screen.getByTestId('calendar-help-panel')).toBeInTheDocument() + }) +}) diff --git a/frontend/src/components/calendar/CalendarHelpPopover.tsx b/frontend/src/components/calendar/CalendarHelpPopover.tsx new file mode 100644 index 0000000..647a929 --- /dev/null +++ b/frontend/src/components/calendar/CalendarHelpPopover.tsx @@ -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(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 ( +
+ + {isOpen && ( + + )} +
+ ) +} diff --git a/frontend/src/components/calendar/PeriodPopover.test.tsx b/frontend/src/components/calendar/PeriodPopover.test.tsx index d42f8e0..4c6e3c6 100644 --- a/frontend/src/components/calendar/PeriodPopover.test.tsx +++ b/frontend/src/components/calendar/PeriodPopover.test.tsx @@ -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() expect(container.firstChild).toBeNull() }) + describe('the delete menu', () => { + it('keeps the three delete scopes hidden until "Ta bort…" is pressed', () => { + render() + 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() + openDeleteMenu() + openDeleteMenu() + expect(screen.queryByTestId('period-delete-group')).not.toBeInTheDocument() + }) + + it('starts folded again each time the popover reopens', () => { + const { rerender } = render() + openDeleteMenu() + rerender() + rerender() + 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() @@ -43,6 +79,7 @@ describe('PeriodPopover', () => { it('shows the ad-hoc "this period" label and disables "every year" (nothing recurring to remove)', () => { render() + 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() + 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() + 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() + 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() + 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() + 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() + 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() + 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() + 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() + 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() + 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() + 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() + 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() - 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() }) diff --git a/frontend/src/components/calendar/PeriodPopover.tsx b/frontend/src/components/calendar/PeriodPopover.tsx index 0faa7fe..a791067 100644 --- a/frontend/src/components/calendar/PeriodPopover.tsx +++ b/frontend/src/components/calendar/PeriodPopover.tsx @@ -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({ ...EMPTY_FORM, startDate, endDate }) const [localError, setLocalError] = useState(null) const [deleteScope, setDeleteScope] = useState(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')} > - × + @@ -496,37 +502,16 @@ export function PeriodPopover({ ) : ( <> -
- - - -
+ + + + + )} )} diff --git a/frontend/src/components/calendar/PresetRail.test.tsx b/frontend/src/components/calendar/PresetRail.test.tsx index 7ba507b..7dc8903 100644 --- a/frontend/src/components/calendar/PresetRail.test.tsx +++ b/frontend/src/components/calendar/PresetRail.test.tsx @@ -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() - 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() + 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() 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') }) }) diff --git a/frontend/src/components/calendar/PresetRail.tsx b/frontend/src/components/calendar/PresetRail.tsx index 1838b9f..f16cc95 100644 --- a/frontend/src/components/calendar/PresetRail.tsx +++ b/frontend/src/components/calendar/PresetRail.tsx @@ -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 ( - + ) } diff --git a/frontend/src/components/calendar/calendar-help-popover.css b/frontend/src/components/calendar/calendar-help-popover.css new file mode 100644 index 0000000..59be733 --- /dev/null +++ b/frontend/src/components/calendar/calendar-help-popover.css @@ -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); +} diff --git a/frontend/src/components/calendar/period-popover.css b/frontend/src/components/calendar/period-popover.css index eb5356d..7b09b89 100644 --- a/frontend/src/components/calendar/period-popover.css +++ b/frontend/src/components/calendar/period-popover.css @@ -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); +} diff --git a/frontend/src/components/calendar/preset-rail.css b/frontend/src/components/calendar/preset-rail.css index e7c40e9..6c7ad87 100644 --- a/frontend/src/components/calendar/preset-rail.css +++ b/frontend/src/components/calendar/preset-rail.css @@ -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; } diff --git a/frontend/src/components/common/CalendarDay.test.tsx b/frontend/src/components/common/CalendarDay.test.tsx index 1273d10..8d42fb1 100644 --- a/frontend/src/components/common/CalendarDay.test.tsx +++ b/frontend/src/components/common/CalendarDay.test.tsx @@ -36,7 +36,14 @@ describe('CalendarDay', () => { const { getByTestId } = render() 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() + 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( - - ) - 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( - - ) - 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( - - ) - 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() - const events = queryByTestId('calendar-day-events') - expect(events).not.toBeInTheDocument() - }) - it('applies today class when isToday is true', () => { const { getByTestId } = render( @@ -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( ) 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( ) 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() 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') }) diff --git a/frontend/src/components/common/CalendarDay.tsx b/frontend/src/components/common/CalendarDay.tsx index 75295f8..befae2e 100644 --- a/frontend/src/components/common/CalendarDay.tsx +++ b/frontend/src/components/common/CalendarDay.tsx @@ -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)} > -
{dayNum}
- {(isRedDay || dayType === 'klamdag') && ( - - )} +
+ {dayNum} + {(isRedDay || dayType === 'klamdag') && ( + + )} + {isOverride && ( + + )} +
{cleanHolidayName && (
{cleanHolidayName}
)} - {isOverride && ( - - )} {isExcluded && ( + // The strike-through numeral is the visible mark; this span only + // keeps a findable hook for tests and the e2e suite. )} - {events.length > 0 && ( -
- {events.map((event, idx) => ( -
- ))} -
- )}
) } diff --git a/frontend/src/components/common/CalendarEvent.test.tsx b/frontend/src/components/common/CalendarEvent.test.tsx index a416722..8ed338d 100644 --- a/frontend/src/components/common/CalendarEvent.test.tsx +++ b/frontend/src/components/common/CalendarEvent.test.tsx @@ -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', () => { diff --git a/frontend/src/components/common/CalendarEvent.tsx b/frontend/src/components/common/CalendarEvent.tsx index af815f5..1204d6f 100644 --- a/frontend/src/components/common/CalendarEvent.tsx +++ b/frontend/src/components/common/CalendarEvent.tsx @@ -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" > - × + )} diff --git a/frontend/src/components/common/CalendarWeek.test.tsx b/frontend/src/components/common/CalendarWeek.test.tsx index 38d531c..7f301af 100644 --- a/frontend/src/components/common/CalendarWeek.test.tsx +++ b/frontend/src/components/common/CalendarWeek.test.tsx @@ -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() + 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() + 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([ + ['2026-01-10', calDay({ date: '2026-01-10', dayType: 'weekend' })], + ['2026-01-11', calDay({ date: '2026-01-11', dayType: 'weekend' })], + ]) + const { container } = render( + , + ) + 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() + 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() 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() 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') }) }) diff --git a/frontend/src/components/common/CalendarWeek.tsx b/frontend/src/components/common/CalendarWeek.tsx index 4dcd30b..819ffa8 100644 --- a/frontend/src/components/common/CalendarWeek.tsx +++ b/frontend/src/components/common/CalendarWeek.tsx @@ -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) diff --git a/frontend/src/components/modals/PlannedDayModal.test.tsx b/frontend/src/components/modals/PlannedDayModal.test.tsx index 9067e09..d4a65f9 100644 --- a/frontend/src/components/modals/PlannedDayModal.test.tsx +++ b/frontend/src/components/modals/PlannedDayModal.test.tsx @@ -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() @@ -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() + it('renders the default (planner-decides) controls when there is no existing day (couple)', () => { + render() 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() 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() + 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() + 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() + 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() + 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() + 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() + 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() + 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() + render() 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() + render() 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() + render() 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() + render() 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() + render() 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() + render() await openAdvanced() expect(screen.getByTestId('day-type-select')).toHaveValue('vacation') }) it('uses defaultDayType when provided and no existing day', async () => { - render() + render() await openAdvanced() expect(screen.getByTestId('day-type-select')).toHaveValue('sjukpenning') }) it('allows changing day type', async () => { - render() + render() 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() + const { rerender } = render() await openAdvanced() - rerender() + rerender() 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() - await openAdvanced() + await pickType('vacation') expect(screen.queryByTestId('parent-select')).not.toBeInTheDocument() }) it('renders parent select when multiple parents', async () => { - render() + render() await openAdvanced() expect(screen.getByTestId('parent-select')).toBeInTheDocument() }) it('shows all parents in select options', async () => { - render() + render() 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() + const { rerender } = render() await openAdvanced() - rerender() + rerender() expect(screen.getByTestId('parent-select')).toBeDisabled() }) }) describe('dubbeldag checkbox (inside Advanced)', () => { it('renders dubbeldag checkbox', async () => { - render() + render() await openAdvanced() expect(screen.getByTestId('dubbeldag-checkbox')).toBeInTheDocument() }) it('defaults to unchecked', async () => { - render() + render() await openAdvanced() expect(screen.getByTestId('dubbeldag-checkbox')).not.toBeChecked() }) it('can be checked for non-vacation day types', async () => { - render() + render() 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() + render() await openAdvanced() expect(screen.getByTestId('dubbeldag-checkbox')).toBeDisabled() }) it('shows hint when dayType is vacation', async () => { - render() + render() await openAdvanced() expect(screen.getByText(/Dubbeldagar gäller inte för semester/)).toBeInTheDocument() }) it('hides hint when dayType is not vacation', async () => { - render() + render() 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() + render() 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() + render() 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() + render() 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() + render() 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() + render() 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() + render() 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() + render() await openAdvanced() expect(screen.queryByTestId('pinned-notice')).not.toBeInTheDocument() }) diff --git a/frontend/src/components/modals/PlannedDayModal.tsx b/frontend/src/components/modals/PlannedDayModal.tsx index 6b15cb1..1187a61 100644 --- a/frontend/src/components/modals/PlannedDayModal.tsx +++ b/frontend/src/components/modals/PlannedDayModal.tsx @@ -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(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(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 ` setIsDubbeldag(e.target.checked)} - disabled={isSaving || dayType === 'vacation'} - data-testid="dubbeldag-checkbox" - /> - {' '}{t('plannedDayModal.doubleDayLabel')} - - {dayType === 'vacation' && ( -

- {t('plannedDayModal.doubleDayHint')} -

- )} - + {dayType !== 'auto' && ( +
+ + {dayType === 'vacation' && ( +

+ {t('plannedDayModal.doubleDayHint')} +

+ )} +
+ )} {existingDay?.isPinned && (

- 📌 {t('plannedDayModal.pinnedNotice')} + + {t('plannedDayModal.pinnedNotice')}

)} diff --git a/frontend/src/components/modals/planned-day-modal.css b/frontend/src/components/modals/planned-day-modal.css index ce226f9..03691de 100644 --- a/frontend/src/components/modals/planned-day-modal.css +++ b/frontend/src/components/modals/planned-day-modal.css @@ -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. */ diff --git a/frontend/src/i18n/en/calendar.ts b/frontend/src/i18n/en/calendar.ts index a0b2882..4b0c62e 100644 --- a/frontend/src/i18n/en/calendar.ts +++ b/frontend/src/i18n/en/calendar.ts @@ -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. diff --git a/frontend/src/i18n/sv/calendar.ts b/frontend/src/i18n/sv/calendar.ts index 01a3350..d83ef54 100644 --- a/frontend/src/i18n/sv/calendar.ts +++ b/frontend/src/i18n/sv/calendar.ts @@ -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. diff --git a/frontend/src/pages/Calendar.test.tsx b/frontend/src/pages/Calendar.test.tsx index 5760142..6cb7723 100644 --- a/frontend/src/pages/Calendar.test.tsx +++ b/frontend/src/pages/Calendar.test.tsx @@ -336,9 +336,8 @@ describe('Calendar', () => { // week" logic would add a 6th row of 7 disabled June cells that serves // no purpose. render() - 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() 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() + 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() - 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() - 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() - 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() - 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() - - 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() 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() 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() - 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', () => { , ) - 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() 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) }) diff --git a/frontend/src/pages/Calendar.tsx b/frontend/src/pages/Calendar.tsx index 12dd076..22a9e65 100644 --- a/frontend/src/pages/Calendar.tsx +++ b/frontend/src/pages/Calendar.tsx @@ -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 (
- -

+
+

+ {monthName(month)} {year} +

+ + +
+
-

- + +
{(syncHook.isSyncing || isCreatingPeriod) && ( @@ -1147,7 +1158,7 @@ export function Calendar(): ReactNode { {toast.undo && ( )} -
-
-
- {showWeekNumbers &&
} - {WEEKDAY_KEYS.map((key) => ( -
- {t(key)} -
- ))} -
+
+ - {/* 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. */} -
- {weeks.map((week, weekIdx) => ( - - ))} -
- -
-

{t('calendar.legendTitle')}

-
-
- - {t('calendar.legendVacation')} -
-
- - {t('calendar.legendIncomeBased')} -
-
- - {t('calendar.legendMinimumRate')} -
-
- - {t('calendar.legendRedDay')} -
-
- - {t('calendar.legendKlamdag')} -
-
- - {t('calendar.legendOverride')} -
-
- 2× - {t('calendar.legendDoubleDay')} -
+
+ {showWeekNumbers &&
} + {WEEKDAY_KEYS.map((key) => ( +
+ {t(key)}
-
+ ))}
- + {/* 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. */} +
+ {weeks.map((week, weekIdx) => ( + + ))} +
{firstSelectedDate && ( diff --git a/frontend/src/pages/calendar.css b/frontend/src/pages/calendar.css index 3da1c51..f8e65bb 100644 --- a/frontend/src/pages/calendar.css +++ b/frontend/src/pages/calendar.css @@ -1,406 +1,196 @@ +/* Calendar — the Almanacka. + * + * A gridless month: no cell borders, radii or fills; one hairline between + * week rows; every day state is carried by the numeral (colour, an R/K + * superscript, a circle for today, a dotted underline for an override, a + * strike-through for a removed day). Bands are 18px lanes overlaid on the + * cells, told apart by fill/ink/outline and a 2px owner cap, never by hue. + * + * Load-bearing for tests (Calendar.test.tsx reads this file as text): + * `.calendar-week` and `.calendar-week--with-week-numbers` keep their + * verbatim `grid-template-columns`, `.calendar-day` / + * `.calendar-week__day-cell` / `.calendar-day__holiday-name` keep + * `min-width: 0`, `.calendar__weeks` keeps `grid-auto-rows: 1fr` and + * `grid-template-columns: minmax(0, 1fr)`. Keep those blocks free of a `}` + * inside a comment. */ + +/* Toolbar + * ------------------------------------------------------------------------ */ .calendar__header { display: flex; align-items: center; - justify-content: space-between; flex-wrap: wrap; - row-gap: var(--sp-2); - margin-bottom: var(--sp-5); + gap: var(--sp-2) var(--sp-4); + margin-bottom: var(--sp-4); +} + +.calendar__nav { + display: flex; + align-items: center; + gap: var(--sp-1); + flex: 1 1 auto; + min-width: 0; } .calendar__title { - margin: 0; + margin: 0 var(--sp-1); font-size: var(--fs-xl); - font-weight: 700; + font-weight: 600; + line-height: var(--lh-tight); + letter-spacing: -0.02em; color: var(--c-text); - display: flex; - flex: 1 1 auto; - min-width: 0; - flex-wrap: wrap; - gap: var(--sp-2); - row-gap: var(--sp-1); - align-items: center; - justify-content: center; + white-space: nowrap; +} + +.calendar__today-button { + flex-shrink: 0; + margin-left: var(--sp-1); +} + +/* The month/year selects, styled bare — text and a chevron, no box. Sized + * by content (`width: auto`) so the global `select { width: 100% }` reset + * can't squeeze the longest month name ("September"). */ +.calendar__jump { + display: flex; + align-items: center; + gap: var(--sp-1); + margin-left: auto; } -/* Sized by content, not a fixed width — the global `select { width: 100% }` - * reset (global.css) is what caused #4's clipped month names ("September" → - * "Septemb"): two selects each fighting for 100% of `.calendar__title`'s - * width shrank below their own text. `min-width` is set in `ch` so the - * longest month name in either locale ("September") always fits alongside - * its chevron — see `src/i18n/sv/calendar.ts`/`en/calendar.ts`. */ .calendar__month-select, .calendar__year-select { width: auto; - min-height: var(--control-h); - background: var(--c-surface); - border: 1px solid var(--c-border); - color: var(--c-text); - font-size: var(--fs-xl); - font-weight: 700; + min-height: var(--control-h-sm); + padding: 0 var(--sp-5) 0 var(--sp-2); + border: 0; + border-radius: var(--radius); + background-color: transparent; + background-position: right var(--sp-2) center; + color: var(--c-text-muted); + font-size: var(--fs-sm); + font-weight: 500; cursor: pointer; - padding: var(--sp-1) 28px var(--sp-1) var(--sp-2); - border-radius: var(--radius-sm); - transition: background-color 0.2s, border-color 0.2s, color 0.2s, box-shadow 0.2s; - appearance: none; - background-image: var(--icon-chevron-down); - background-repeat: no-repeat; - background-position: right 6px center; - background-size: 12px; -} - -.calendar__month-select { - min-width: 12ch; -} - -.calendar__year-select { - min-width: 7ch; + transition: background-color 0.15s, color 0.15s; } .calendar__month-select:hover, .calendar__year-select:hover { - background-color: var(--c-bg); - border-color: var(--c-accent); - color: var(--c-accent); + background-color: var(--c-accent-weak); + color: var(--c-text); +} + +.calendar__month-select:focus, +.calendar__year-select:focus { + box-shadow: none; } .calendar__month-select:focus-visible, .calendar__year-select:focus-visible { - outline: none; - border-color: var(--c-accent); - box-shadow: 0 0 0 3px var(--c-focus-ring); + outline: 2px solid var(--c-accent); + outline-offset: 1px; } -.calendar__month-select:active, -.calendar__year-select:active { - background-color: var(--c-border); +/* Toast — a single line, no box: the message, an inline undo link and an + * icon close. */ +.calendar__toast { + display: flex; + align-items: center; + gap: var(--sp-3); + margin-bottom: var(--sp-3); + font-size: var(--fs-sm); + color: var(--c-text); } -/* `.btn` has no fixed width of its own — it sizes to its label already — but - * we still pin `flex-shrink: 0` here so a long locale ("Today" vs "Idag") - * never gets squeezed by the two `