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:
@@ -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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user