Phase 2: Build 9 presentational components with full test coverage

New components (all with .tsx + .test.tsx, 90%+ coverage):
- CalendarDay: day cell with event badges
- CalendarEvent: colored event chip with dismiss option
- DaysBreakdown: table of days by type
- ProgressChart: progress bar toward age-4 deadline
- TipCard: optimization tip card with severity badges
- VacationDaysInput: number input with min/max validation
- ParentProfileInput: dual selects for salary type & employment status
- PlanningGoalSelector: radio buttons for planning goal
- BreakPeriodsInput: list manager with inline add/edit form

All components follow existing patterns from Header/Footer:
- Typed props interfaces (exported)
- Semantic HTML with data-testid & ARIA attributes
- No business logic, purely presentational
- Comprehensive test coverage including edge cases

npm test: 22 files, 167 tests passing
npx tsc --noEmit: clean

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-06-01 17:50:23 +02:00
parent faebcced8a
commit 425e29ea21
19 changed files with 2048 additions and 0 deletions
@@ -0,0 +1,96 @@
import { describe, it, expect, vi } from 'vitest'
import { render, fireEvent } from '@testing-library/react'
import { CalendarDay } from './CalendarDay'
describe('CalendarDay', () => {
it('renders day number', () => {
const { getByTestId } = render(<CalendarDay date="2026-06-15" />)
const day = getByTestId('calendar-day')
expect(day).toBeInTheDocument()
expect(day.textContent).toContain('15')
})
it('renders as disabled when isCurrentMonth is false', () => {
const { getByTestId } = render(
<CalendarDay date="2026-06-15" isCurrentMonth={false} />
)
const day = getByTestId('calendar-day')
expect(day).toHaveClass('calendar-day--disabled')
})
it('renders events when provided', () => {
const { getByTestId } = render(
<CalendarDay
date="2026-06-15"
events={[
{ type: 'vacation', color: '#FF5733', title: 'Vacation' },
{ type: 'sick', color: '#FFD700', title: 'Sick leave' },
]}
/>
)
const events = getByTestId('calendar-day-events')
expect(events).toBeInTheDocument()
expect(events.children).toHaveLength(2)
})
it('applies has-events class when events exist', () => {
const { getByTestId } = render(
<CalendarDay
date="2026-06-15"
events={[{ type: 'vacation', color: '#FF5733' }]}
/>
)
const day = getByTestId('calendar-day')
expect(day).toHaveClass('calendar-day--has-events')
})
it('calls onClick when clicked', () => {
const onClick = vi.fn()
const { getByTestId } = render(
<CalendarDay date="2026-06-15" onClick={onClick} />
)
const day = getByTestId('calendar-day')
fireEvent.click(day)
expect(onClick).toHaveBeenCalledTimes(1)
})
it('calls onClick on Enter key', () => {
const onClick = vi.fn()
const { getByTestId } = render(
<CalendarDay date="2026-06-15" onClick={onClick} />
)
const day = getByTestId('calendar-day')
fireEvent.keyDown(day, { key: 'Enter' })
expect(onClick).toHaveBeenCalledTimes(1)
})
it('calls onClick on Space key', () => {
const onClick = vi.fn()
const { getByTestId } = render(
<CalendarDay date="2026-06-15" onClick={onClick} />
)
const day = getByTestId('calendar-day')
fireEvent.keyDown(day, { key: ' ' })
expect(onClick).toHaveBeenCalledTimes(1)
})
it('provides accessibility label with event count', () => {
const { getByTestId } = render(
<CalendarDay
date="2026-06-15"
events={[
{ type: 'vacation', color: '#FF5733' },
{ type: 'sick', color: '#FFD700' },
]}
/>
)
const day = getByTestId('calendar-day')
expect(day).toHaveAttribute('aria-label', '2026-06-15 with 2 event(s)')
})
it('renders without events by default', () => {
const { queryByTestId } = render(<CalendarDay date="2026-06-15" />)
const events = queryByTestId('calendar-day-events')
expect(events).not.toBeInTheDocument()
})
})
@@ -0,0 +1,48 @@
import { ReactNode } from 'react'
export interface CalendarDayProps {
date: string
events?: { type: string; color: string; title?: string }[]
isCurrentMonth?: boolean
onClick?: () => void
}
export function CalendarDay({
date,
events = [],
isCurrentMonth = true,
onClick,
}: CalendarDayProps): ReactNode {
const dayNum = new Date(date + 'T00:00:00Z').getUTCDate()
return (
<div
data-testid="calendar-day"
onClick={onClick}
role="button"
tabIndex={0}
aria-label={`${date}${events.length > 0 ? ` with ${events.length} event(s)` : ''}`}
className={`calendar-day ${!isCurrentMonth ? 'calendar-day--disabled' : ''} ${events.length > 0 ? 'calendar-day--has-events' : ''}`}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
onClick?.()
}
}}
>
<div className="calendar-day__number">{dayNum}</div>
{events.length > 0 && (
<div className="calendar-day__events" data-testid="calendar-day-events">
{events.map((event, idx) => (
<div
key={idx}
className="calendar-day__event"
style={{ backgroundColor: event.color }}
title={event.title ?? event.type}
data-testid={`event-${idx}`}
/>
))}
</div>
)}
</div>
)
}
@@ -0,0 +1,75 @@
import { describe, it, expect, vi } from 'vitest'
import { render, fireEvent } from '@testing-library/react'
import { CalendarEvent } from './CalendarEvent'
describe('CalendarEvent', () => {
it('renders event with leave type label', () => {
const { getByTestId } = render(
<CalendarEvent leaveType="vacation" color="#FF5733" />
)
const event = getByTestId('calendar-event')
expect(event).toBeInTheDocument()
expect(event.textContent).toContain('vacation')
})
it('applies color style', () => {
const { getByTestId } = render(
<CalendarEvent leaveType="vacation" color="#FF5733" />
)
const event = getByTestId('calendar-event')
expect(event).toHaveStyle({ backgroundColor: '#FF5733' })
})
it('displays custom title on hover', () => {
const { getByTitle } = render(
<CalendarEvent leaveType="vacation" color="#FF5733" title="Summer vacation" />
)
expect(getByTitle('Summer vacation')).toBeInTheDocument()
})
it('renders dismiss button when onDismiss provided', () => {
const { getByTestId } = render(
<CalendarEvent leaveType="vacation" color="#FF5733" onDismiss={() => {}} />
)
const button = getByTestId('dismiss-button')
expect(button).toBeInTheDocument()
expect(button.textContent).toBe('×')
})
it('does not render dismiss button when onDismiss not provided', () => {
const { queryByTestId } = render(
<CalendarEvent leaveType="vacation" color="#FF5733" />
)
expect(queryByTestId('dismiss-button')).not.toBeInTheDocument()
})
it('calls onDismiss when dismiss button clicked', () => {
const onDismiss = vi.fn()
const { getByTestId } = render(
<CalendarEvent leaveType="vacation" color="#FF5733" onDismiss={onDismiss} />
)
const button = getByTestId('dismiss-button')
fireEvent.click(button)
expect(onDismiss).toHaveBeenCalledTimes(1)
})
it('stops propagation on dismiss button click', () => {
const onDismiss = vi.fn()
const { getByTestId } = render(
<CalendarEvent leaveType="vacation" color="#FF5733" onDismiss={onDismiss} />
)
const button = getByTestId('dismiss-button')
const event = new MouseEvent('click', { bubbles: true })
const stopPropagation = vi.spyOn(event, 'stopPropagation')
fireEvent(button, event)
expect(stopPropagation).toHaveBeenCalled()
})
it('provides accessibility label', () => {
const { getByTestId } = render(
<CalendarEvent leaveType="vacation" color="#FF5733" onDismiss={() => {}} />
)
const button = getByTestId('dismiss-button')
expect(button).toHaveAttribute('aria-label', 'Dismiss vacation')
})
})
@@ -0,0 +1,40 @@
import { ReactNode } from 'react'
export interface CalendarEventProps {
leaveType: string
color: string
title?: string
onDismiss?: () => void
}
export function CalendarEvent({
leaveType,
color,
title,
onDismiss,
}: CalendarEventProps): ReactNode {
return (
<div
data-testid="calendar-event"
className="calendar-event"
style={{ backgroundColor: color }}
>
<span className="calendar-event__label" title={title ?? leaveType}>
{leaveType}
</span>
{onDismiss && (
<button
className="calendar-event__dismiss"
onClick={(e) => {
e.stopPropagation()
onDismiss()
}}
aria-label={`Dismiss ${leaveType}`}
data-testid="dismiss-button"
>
×
</button>
)}
</div>
)
}