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>
)
}
@@ -0,0 +1,50 @@
import { describe, it, expect } from 'vitest'
import { render } from '@testing-library/react'
import { DaysBreakdown } from './DaysBreakdown'
describe('DaysBreakdown', () => {
it('renders empty state when no breakdown provided', () => {
const { getByTestId } = render(<DaysBreakdown breakdown={[]} />)
expect(getByTestId('days-breakdown').textContent).toContain('No days recorded yet')
})
it('renders table with breakdown data', () => {
const breakdown = [
{ type: 'vacation', count: 10 },
{ type: 'sick leave', count: 3 },
{ type: 'parental', count: 50 },
]
const { getByTestId } = render(<DaysBreakdown breakdown={breakdown} />)
const table = getByTestId('days-breakdown')
expect(table.querySelector('table')).toBeInTheDocument()
expect(table.textContent).toContain('vacation')
expect(table.textContent).toContain('10')
})
it('renders correct number of rows', () => {
const breakdown = [
{ type: 'vacation', count: 10 },
{ type: 'sick leave', count: 3 },
{ type: 'parental', count: 50 },
]
const { getByTestId } = render(<DaysBreakdown breakdown={breakdown} />)
expect(getByTestId('breakdown-row-vacation')).toBeInTheDocument()
expect(getByTestId('breakdown-row-sick leave')).toBeInTheDocument()
expect(getByTestId('breakdown-row-parental')).toBeInTheDocument()
})
it('displays correct values in table', () => {
const breakdown = [{ type: 'vacation', count: 25 }]
const { getByTestId } = render(<DaysBreakdown breakdown={breakdown} />)
const row = getByTestId('breakdown-row-vacation')
expect(row.textContent).toContain('vacation')
expect(row.textContent).toContain('25')
})
it('renders table headers', () => {
const breakdown = [{ type: 'vacation', count: 10 }]
const { getByTestId } = render(<DaysBreakdown breakdown={breakdown} />)
const table = getByTestId('days-breakdown').querySelector('table')
expect(table?.querySelector('thead')).toBeInTheDocument()
})
})
@@ -0,0 +1,36 @@
import { ReactNode } from 'react'
export interface DaysBreakdownProps {
breakdown: Array<{ type: string; count: number }>
}
export function DaysBreakdown({ breakdown }: DaysBreakdownProps): ReactNode {
if (!breakdown || breakdown.length === 0) {
return (
<div data-testid="days-breakdown" className="days-breakdown">
<p className="days-breakdown__empty">No days recorded yet</p>
</div>
)
}
return (
<div data-testid="days-breakdown" className="days-breakdown">
<table className="days-breakdown__table">
<thead>
<tr>
<th>Leave Type</th>
<th>Days</th>
</tr>
</thead>
<tbody>
{breakdown.map((item, idx) => (
<tr key={idx} data-testid={`breakdown-row-${item.type}`}>
<td>{item.type}</td>
<td>{item.count}</td>
</tr>
))}
</tbody>
</table>
</div>
)
}
@@ -0,0 +1,63 @@
import { describe, it, expect } from 'vitest'
import { render } from '@testing-library/react'
import { ProgressChart } from './ProgressChart'
describe('ProgressChart', () => {
it('renders progress chart', () => {
const { getByTestId } = render(
<ProgressChart currentWeeks={10} targetWeeks={50} />
)
expect(getByTestId('progress-chart')).toBeInTheDocument()
})
it('calculates percentage correctly', () => {
const { getByTestId } = render(
<ProgressChart currentWeeks={25} targetWeeks={100} />
)
expect(getByTestId('percentage').textContent).toBe('25%')
})
it('displays weeks correctly', () => {
const { getByTestId } = render(
<ProgressChart currentWeeks={10} targetWeeks={50} />
)
expect(getByTestId('current-weeks').textContent).toBe('10 / 50 weeks')
})
it('sets fill width based on percentage', () => {
const { getByTestId } = render(
<ProgressChart currentWeeks={50} targetWeeks={100} />
)
const fill = getByTestId('progress-fill')
expect(fill).toHaveStyle({ width: '50%' })
})
it('caps percentage at 100%', () => {
const { getByTestId } = render(
<ProgressChart currentWeeks={150} targetWeeks={100} />
)
expect(getByTestId('percentage').textContent).toBe('100%')
expect(getByTestId('progress-fill')).toHaveStyle({ width: '100%' })
})
it('shows 0% when no progress', () => {
const { getByTestId } = render(
<ProgressChart currentWeeks={0} targetWeeks={50} />
)
expect(getByTestId('percentage').textContent).toBe('0%')
})
it('displays child age when provided', () => {
const { getByTestId } = render(
<ProgressChart currentWeeks={10} targetWeeks={50} childAge={2} />
)
expect(getByTestId('progress-chart').textContent).toContain('Age: 2')
})
it('handles zero target weeks', () => {
const { getByTestId } = render(
<ProgressChart currentWeeks={10} targetWeeks={0} />
)
expect(getByTestId('percentage').textContent).toBe('0%')
})
})
@@ -0,0 +1,39 @@
import { ReactNode } from 'react'
export interface ProgressChartProps {
currentWeeks: number
targetWeeks: number
childAge?: number
}
export function ProgressChart({
currentWeeks,
targetWeeks,
childAge,
}: ProgressChartProps): ReactNode {
const percentage = targetWeeks > 0 ? Math.min((currentWeeks / targetWeeks) * 100, 100) : 0
return (
<div data-testid="progress-chart" className="progress-chart">
<div className="progress-chart__header">
<h3>Parental Leave Progress</h3>
{childAge !== undefined && <span className="progress-chart__age">Age: {childAge}</span>}
</div>
<div className="progress-chart__bar">
<div
className="progress-chart__fill"
style={{ width: `${percentage}%` }}
data-testid="progress-fill"
/>
</div>
<div className="progress-chart__stats">
<span data-testid="current-weeks">
{currentWeeks} / {targetWeeks} weeks
</span>
<span data-testid="percentage" className="progress-chart__percentage">
{Math.round(percentage)}%
</span>
</div>
</div>
)
}
@@ -0,0 +1,116 @@
import { describe, it, expect, vi } from 'vitest'
import { render, fireEvent } from '@testing-library/react'
import { BreakPeriodsInput } from './BreakPeriodsInput'
describe('BreakPeriodsInput', () => {
const mockPeriods = [
{
id: '1',
name: 'Summer',
startDate: '2026-06-01',
endDate: '2026-08-31',
targetWeeks: 12,
},
]
it('renders break periods list', () => {
const { getByTestId } = render(
<BreakPeriodsInput periods={mockPeriods} onAdd={() => {}} onRemove={() => {}} />
)
expect(getByTestId('period-1')).toBeInTheDocument()
expect(getByTestId('period-1').textContent).toContain('Summer')
})
it('shows empty state when no periods', () => {
const { getByTestId } = render(
<BreakPeriodsInput periods={[]} onAdd={() => {}} onRemove={() => {}} />
)
expect(getByTestId('empty-state')).toBeInTheDocument()
})
it('displays add button', () => {
const { getByTestId } = render(
<BreakPeriodsInput periods={[]} onAdd={() => {}} onRemove={() => {}} />
)
expect(getByTestId('add-button')).toBeInTheDocument()
})
it('shows form when add button clicked', () => {
const { getByTestId, queryByTestId } = render(
<BreakPeriodsInput periods={[]} onAdd={() => {}} onRemove={() => {}} />
)
expect(queryByTestId('add-form')).not.toBeInTheDocument()
fireEvent.click(getByTestId('add-button'))
expect(getByTestId('add-form')).toBeInTheDocument()
})
it('calls onAdd with form data', () => {
const onAdd = vi.fn()
const { getByTestId } = render(
<BreakPeriodsInput periods={[]} onAdd={onAdd} onRemove={() => {}} />
)
fireEvent.click(getByTestId('add-button'))
fireEvent.change(getByTestId('period-name'), { target: { value: 'Winter' } })
fireEvent.change(getByTestId('period-start'), { target: { value: '2026-12-01' } })
fireEvent.change(getByTestId('period-end'), { target: { value: '2026-12-31' } })
fireEvent.change(getByTestId('period-weeks'), { target: { value: '4' } })
fireEvent.click(getByTestId('submit-button'))
expect(onAdd).toHaveBeenCalledWith({
name: 'Winter',
startDate: '2026-12-01',
endDate: '2026-12-31',
targetWeeks: 4,
})
})
it('calls onRemove when remove button clicked', () => {
const onRemove = vi.fn()
const { getByTestId } = render(
<BreakPeriodsInput periods={mockPeriods} onAdd={() => {}} onRemove={onRemove} />
)
fireEvent.click(getByTestId('remove-1'))
expect(onRemove).toHaveBeenCalledWith('1')
})
it('closes form on cancel', () => {
const { getByTestId, queryByTestId } = render(
<BreakPeriodsInput periods={[]} onAdd={() => {}} onRemove={() => {}} />
)
fireEvent.click(getByTestId('add-button'))
expect(getByTestId('add-form')).toBeInTheDocument()
fireEvent.click(getByTestId('cancel-button'))
expect(queryByTestId('add-form')).not.toBeInTheDocument()
})
it('disables submit when fields incomplete', () => {
const { getByTestId } = render(
<BreakPeriodsInput periods={[]} onAdd={() => {}} onRemove={() => {}} />
)
fireEvent.click(getByTestId('add-button'))
const submit = getByTestId('submit-button') as HTMLButtonElement
expect(submit.disabled).toBe(true)
fireEvent.change(getByTestId('period-name'), { target: { value: 'Winter' } })
fireEvent.change(getByTestId('period-start'), { target: { value: '2026-12-01' } })
fireEvent.change(getByTestId('period-end'), { target: { value: '2026-12-31' } })
expect(submit.disabled).toBe(false)
})
it('disables buttons when loading', () => {
const { getByTestId } = render(
<BreakPeriodsInput
periods={mockPeriods}
onAdd={() => {}}
onRemove={() => {}}
isLoading
/>
)
expect((getByTestId('add-button') as HTMLButtonElement).disabled).toBe(true)
expect((getByTestId('remove-1') as HTMLButtonElement).disabled).toBe(true)
})
})
@@ -0,0 +1,153 @@
import { ReactNode, useState } from 'react'
export interface BreakPeriodsInputProps {
periods: Array<{ id: string; name: string; startDate: string; endDate: string; targetWeeks: number }>
onAdd: (period: Omit<any, 'id'>) => void
onRemove: (id: string) => void
isLoading?: boolean
}
export function BreakPeriodsInput({
periods,
onAdd,
onRemove,
isLoading = false,
}: BreakPeriodsInputProps): ReactNode {
const [formOpen, setFormOpen] = useState(false)
const [name, setName] = useState('')
const [startDate, setStartDate] = useState('')
const [endDate, setEndDate] = useState('')
const [targetWeeks, setTargetWeeks] = useState('0')
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault()
if (name && startDate && endDate && targetWeeks) {
onAdd({ name, startDate, endDate, targetWeeks: parseInt(targetWeeks, 10) })
setName('')
setStartDate('')
setEndDate('')
setTargetWeeks('0')
setFormOpen(false)
}
}
return (
<div data-testid="break-periods-input" className="break-periods-input">
<h3 className="break-periods-input__title">Break Periods</h3>
{periods.length === 0 ? (
<p className="break-periods-input__empty" data-testid="empty-state">
No break periods defined yet
</p>
) : (
<ul className="break-periods-input__list">
{periods.map((period) => (
<li
key={period.id}
className="break-periods-input__item"
data-testid={`period-${period.id}`}
>
<div className="break-periods-input__info">
<strong>{period.name}</strong>
<small>
{period.startDate} to {period.endDate} ({period.targetWeeks} weeks)
</small>
</div>
<button
className="break-periods-input__remove"
onClick={() => onRemove(period.id)}
disabled={isLoading}
aria-label={`Remove ${period.name}`}
data-testid={`remove-${period.id}`}
>
Remove
</button>
</li>
))}
</ul>
)}
{formOpen && (
<form
onSubmit={handleSubmit}
className="break-periods-input__form"
data-testid="add-form"
>
<div className="break-periods-input__field">
<label htmlFor="period-name">Name</label>
<input
id="period-name"
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="e.g., Summer"
data-testid="period-name"
/>
</div>
<div className="break-periods-input__field">
<label htmlFor="period-start">Start Date</label>
<input
id="period-start"
type="date"
value={startDate}
onChange={(e) => setStartDate(e.target.value)}
data-testid="period-start"
/>
</div>
<div className="break-periods-input__field">
<label htmlFor="period-end">End Date</label>
<input
id="period-end"
type="date"
value={endDate}
onChange={(e) => setEndDate(e.target.value)}
data-testid="period-end"
/>
</div>
<div className="break-periods-input__field">
<label htmlFor="period-weeks">Target Weeks</label>
<input
id="period-weeks"
type="number"
min="0"
value={targetWeeks}
onChange={(e) => setTargetWeeks(e.target.value)}
data-testid="period-weeks"
/>
</div>
<div className="break-periods-input__actions">
<button
type="submit"
disabled={isLoading || !name || !startDate || !endDate}
data-testid="submit-button"
>
Add Period
</button>
<button
type="button"
onClick={() => setFormOpen(false)}
data-testid="cancel-button"
>
Cancel
</button>
</div>
</form>
)}
{!formOpen && (
<button
onClick={() => setFormOpen(true)}
className="break-periods-input__add"
disabled={isLoading}
data-testid="add-button"
>
+ Add Break Period
</button>
)}
</div>
)
}
@@ -0,0 +1,81 @@
import { describe, it, expect, vi } from 'vitest'
import { render, fireEvent } from '@testing-library/react'
import { ParentProfileInput } from './ParentProfileInput'
describe('ParentProfileInput', () => {
it('renders salary type and employment status selects', () => {
const { getByTestId } = render(
<ParentProfileInput parent={{}} onChange={() => {}} />
)
expect(getByTestId('salary-type')).toBeInTheDocument()
expect(getByTestId('employment-status')).toBeInTheDocument()
})
it('displays current salary type', () => {
const { getByTestId } = render(
<ParentProfileInput
parent={{ salaryType: 'hourly' }}
onChange={() => {}}
/>
)
const select = getByTestId('salary-type') as HTMLSelectElement
expect(select.value).toBe('hourly')
})
it('displays current employment status', () => {
const { getByTestId } = render(
<ParentProfileInput
parent={{ employmentStatus: 'self_employed' }}
onChange={() => {}}
/>
)
const select = getByTestId('employment-status') as HTMLSelectElement
expect(select.value).toBe('self_employed')
})
it('calls onChange for salary type change', () => {
const onChange = vi.fn()
const { getByTestId } = render(
<ParentProfileInput parent={{}} onChange={onChange} />
)
const select = getByTestId('salary-type')
fireEvent.change(select, { target: { value: 'monthly' } })
expect(onChange).toHaveBeenCalledWith('salaryType', 'monthly')
})
it('calls onChange for employment status change', () => {
const onChange = vi.fn()
const { getByTestId } = render(
<ParentProfileInput parent={{}} onChange={onChange} />
)
const select = getByTestId('employment-status')
fireEvent.change(select, { target: { value: 'employed' } })
expect(onChange).toHaveBeenCalledWith('employmentStatus', 'employed')
})
it('renders all salary type options', () => {
const { getByTestId } = render(
<ParentProfileInput parent={{}} onChange={() => {}} />
)
const select = getByTestId('salary-type')
expect((select as HTMLSelectElement).options.length).toBeGreaterThanOrEqual(4)
})
it('renders all employment status options', () => {
const { getByTestId } = render(
<ParentProfileInput parent={{}} onChange={() => {}} />
)
const select = getByTestId('employment-status')
expect((select as HTMLSelectElement).options.length).toBeGreaterThanOrEqual(4)
})
it('handles undefined parent properties', () => {
const { getByTestId } = render(
<ParentProfileInput parent={{}} onChange={() => {}} />
)
const salaryType = getByTestId('salary-type') as HTMLSelectElement
const employmentStatus = getByTestId('employment-status') as HTMLSelectElement
expect(salaryType.value).toBe('')
expect(employmentStatus.value).toBe('')
})
})
@@ -0,0 +1,54 @@
import { ReactNode } from 'react'
export interface ParentProfileInputProps {
parent: {
salaryType?: string
employmentStatus?: string
}
onChange: (field: string, val: string) => void
}
export function ParentProfileInput({
parent,
onChange,
}: ParentProfileInputProps): ReactNode {
return (
<div data-testid="parent-profile-input" className="parent-profile-input">
<div className="parent-profile-input__field">
<label htmlFor="salary-type" className="parent-profile-input__label">
Salary Type
</label>
<select
id="salary-type"
value={parent.salaryType || ''}
onChange={(e) => onChange('salaryType', e.target.value)}
data-testid="salary-type"
className="parent-profile-input__select"
>
<option value="">Choose salary type</option>
<option value="monthly">Monthly</option>
<option value="hourly">Hourly</option>
<option value="self_employed">Self Employed</option>
</select>
</div>
<div className="parent-profile-input__field">
<label htmlFor="employment-status" className="parent-profile-input__label">
Employment Status
</label>
<select
id="employment-status"
value={parent.employmentStatus || ''}
onChange={(e) => onChange('employmentStatus', e.target.value)}
data-testid="employment-status"
className="parent-profile-input__select"
>
<option value="">Choose employment status</option>
<option value="employed">Employed</option>
<option value="self_employed">Self Employed</option>
<option value="parental_leave">On Parental Leave</option>
</select>
</div>
</div>
)
}
@@ -0,0 +1,74 @@
import { describe, it, expect, vi } from 'vitest'
import { render, fireEvent } from '@testing-library/react'
import { PlanningGoalSelector } from './PlanningGoalSelector'
describe('PlanningGoalSelector', () => {
it('renders three planning goal options', () => {
const { getByTestId } = render(
<PlanningGoalSelector onChange={() => {}} />
)
expect(getByTestId('goal-maximize-contiguous')).toBeInTheDocument()
expect(getByTestId('goal-minimize-stress')).toBeInTheDocument()
expect(getByTestId('goal-balance')).toBeInTheDocument()
})
it('renders as radio buttons', () => {
const { getByTestId } = render(
<PlanningGoalSelector onChange={() => {}} />
)
const radio1 = getByTestId('goal-maximize-contiguous') as HTMLInputElement
const radio2 = getByTestId('goal-minimize-stress') as HTMLInputElement
expect(radio1.type).toBe('radio')
expect(radio2.type).toBe('radio')
})
it('checks selected value', () => {
const { getByTestId } = render(
<PlanningGoalSelector value="minimize_stress" onChange={() => {}} />
)
const radio = getByTestId('goal-minimize-stress') as HTMLInputElement
expect(radio.checked).toBe(true)
})
it('calls onChange when selecting option', () => {
const onChange = vi.fn()
const { getByTestId } = render(
<PlanningGoalSelector onChange={onChange} />
)
const radio = getByTestId('goal-balance')
fireEvent.click(radio)
expect(onChange).toHaveBeenCalledWith('balance')
})
it('updates selection when value changes', () => {
const { getByTestId, rerender } = render(
<PlanningGoalSelector value="maximize_contiguous" onChange={() => {}} />
)
let radio = getByTestId('goal-maximize-contiguous') as HTMLInputElement
expect(radio.checked).toBe(true)
rerender(<PlanningGoalSelector value="balance" onChange={() => {}} />)
radio = getByTestId('goal-balance') as HTMLInputElement
expect(radio.checked).toBe(true)
})
it('only one option can be selected at a time', () => {
const { getByTestId } = render(
<PlanningGoalSelector value="maximize_contiguous" onChange={() => {}} />
)
const radio1 = getByTestId('goal-maximize-contiguous') as HTMLInputElement
const radio2 = getByTestId('goal-minimize-stress') as HTMLInputElement
expect(radio1.checked).toBe(true)
expect(radio2.checked).toBe(false)
})
it('has no selection when value is undefined', () => {
const { getByTestId } = render(
<PlanningGoalSelector onChange={() => {}} />
)
const radio1 = getByTestId('goal-maximize-contiguous') as HTMLInputElement
const radio2 = getByTestId('goal-minimize-stress') as HTMLInputElement
expect(radio1.checked).toBe(false)
expect(radio2.checked).toBe(false)
})
})
@@ -0,0 +1,55 @@
import { ReactNode } from 'react'
export interface PlanningGoalSelectorProps {
value?: string
onChange: (goal: string) => void
}
export function PlanningGoalSelector({ value, onChange }: PlanningGoalSelectorProps): ReactNode {
return (
<div data-testid="planning-goal-selector" className="planning-goal-selector">
<fieldset className="planning-goal-selector__fieldset">
<legend className="planning-goal-selector__legend">Planning Goal</legend>
<div className="planning-goal-selector__option">
<input
type="radio"
id="goal-maximize-contiguous"
name="planning-goal"
value="maximize_contiguous"
checked={value === 'maximize_contiguous'}
onChange={(e) => onChange(e.target.value)}
data-testid="goal-maximize-contiguous"
/>
<label htmlFor="goal-maximize-contiguous">Maximize Contiguous</label>
</div>
<div className="planning-goal-selector__option">
<input
type="radio"
id="goal-minimize-stress"
name="planning-goal"
value="minimize_stress"
checked={value === 'minimize_stress'}
onChange={(e) => onChange(e.target.value)}
data-testid="goal-minimize-stress"
/>
<label htmlFor="goal-minimize-stress">Minimize Stress</label>
</div>
<div className="planning-goal-selector__option">
<input
type="radio"
id="goal-balance"
name="planning-goal"
value="balance"
checked={value === 'balance'}
onChange={(e) => onChange(e.target.value)}
data-testid="goal-balance"
/>
<label htmlFor="goal-balance">Balance</label>
</div>
</fieldset>
</div>
)
}
@@ -0,0 +1,72 @@
import { describe, it, expect, vi } from 'vitest'
import { render, fireEvent } from '@testing-library/react'
import { TipCard } from './TipCard'
describe('TipCard', () => {
const mockTip = {
id: 'tip1',
severity: 'info' as const,
messageKey: 'wrap_red_days',
data: {},
}
it('renders tip card with title', () => {
const { getByTestId } = render(<TipCard tip={mockTip} />)
expect(getByTestId('tip-card')).toBeInTheDocument()
expect(getByTestId('tip-card').textContent).toContain('Wrap Red Days')
})
it('applies severity class', () => {
const { getByTestId } = render(<TipCard tip={mockTip} />)
expect(getByTestId('tip-card')).toHaveClass('tip-card--info')
})
it('renders warning severity class', () => {
const warningTip = { ...mockTip, severity: 'warning' as const }
const { getByTestId } = render(<TipCard tip={warningTip} />)
expect(getByTestId('tip-card')).toHaveClass('tip-card--warning')
})
it('renders action severity class', () => {
const actionTip = { ...mockTip, severity: 'action' as const }
const { getByTestId } = render(<TipCard tip={actionTip} />)
expect(getByTestId('tip-card')).toHaveClass('tip-card--action')
})
it('renders dismiss button when onDismiss provided', () => {
const { getByTestId } = render(
<TipCard tip={mockTip} onDismiss={() => {}} />
)
expect(getByTestId('dismiss-button')).toBeInTheDocument()
})
it('does not render dismiss button when onDismiss not provided', () => {
const { queryByTestId } = render(<TipCard tip={mockTip} />)
expect(queryByTestId('dismiss-button')).not.toBeInTheDocument()
})
it('calls onDismiss with tip id when dismissed', () => {
const onDismiss = vi.fn()
const { getByTestId } = render(
<TipCard tip={mockTip} onDismiss={onDismiss} />
)
fireEvent.click(getByTestId('dismiss-button'))
expect(onDismiss).toHaveBeenCalledWith('tip1')
})
it('displays message key as body text', () => {
const { getByTestId } = render(<TipCard tip={mockTip} />)
expect(getByTestId('tip-card').textContent).toContain('wrap_red_days')
})
it('provides accessibility label', () => {
const { getByTestId } = render(<TipCard tip={mockTip} />)
expect(getByTestId('tip-card')).toHaveAttribute('aria-label', 'Tip: Wrap Red Days')
})
it('maps unknown messageKey to itself', () => {
const unknownTip = { ...mockTip, messageKey: 'unknown_key' }
const { getByTestId } = render(<TipCard tip={unknownTip} />)
expect(getByTestId('tip-card').textContent).toContain('unknown_key')
})
})
@@ -0,0 +1,51 @@
import { ReactNode } from 'react'
export interface TipCardProps {
tip: {
id: string
severity: 'info' | 'warning' | 'action'
messageKey: string
data: Record<string, unknown>
}
onDismiss?: (tipId: string) => void
}
export function TipCard({ tip, onDismiss }: TipCardProps): ReactNode {
const titleMap: Record<string, string> = {
wrap_red_days: 'Wrap Red Days',
prioritize_dubbeldagar: 'Use Dubbeldagar',
extend_contiguous: 'Extend Contiguous Leave',
}
const title = titleMap[tip.messageKey] || tip.messageKey
const severityClassMap = {
info: 'tip-card--info',
warning: 'tip-card--warning',
action: 'tip-card--action',
}
return (
<div
data-testid="tip-card"
className={`tip-card ${severityClassMap[tip.severity]}`}
role="article"
aria-label={`Tip: ${title}`}
>
<div className="tip-card__header">
<h4 className="tip-card__title">{title}</h4>
{onDismiss && (
<button
className="tip-card__dismiss"
onClick={() => onDismiss(tip.id)}
aria-label={`Dismiss: ${title}`}
data-testid="dismiss-button"
>
×
</button>
)}
</div>
<p className="tip-card__body">{tip.messageKey}</p>
</div>
)
}
@@ -0,0 +1,85 @@
import { describe, it, expect, vi } from 'vitest'
import { render, fireEvent } from '@testing-library/react'
import { VacationDaysInput } from './VacationDaysInput'
describe('VacationDaysInput', () => {
it('renders input with label', () => {
const { getByTestId, getByText } = render(
<VacationDaysInput label="Annual vacation days" value={25} onChange={() => {}} />
)
expect(getByTestId('vacation-days-input')).toBeInTheDocument()
expect(getByText('Annual vacation days')).toBeInTheDocument()
})
it('displays current value', () => {
const { getByTestId } = render(
<VacationDaysInput label="Vacation" value={30} onChange={() => {}} />
)
const input = getByTestId('vacation-days-field') as HTMLInputElement
expect(input.value).toBe('30')
})
it('calls onChange with new value', () => {
const onChange = vi.fn()
const { getByTestId } = render(
<VacationDaysInput label="Vacation" value={25} onChange={onChange} />
)
const input = getByTestId('vacation-days-field')
fireEvent.change(input, { target: { value: '30' } })
expect(onChange).toHaveBeenCalledWith(30)
})
it('enforces min constraint', () => {
const onChange = vi.fn()
const { getByTestId } = render(
<VacationDaysInput
label="Vacation"
value={10}
onChange={onChange}
min={5}
/>
)
const input = getByTestId('vacation-days-field')
fireEvent.change(input, { target: { value: '2' } })
expect(onChange).toHaveBeenCalledWith(5) // min enforced
})
it('enforces max constraint', () => {
const onChange = vi.fn()
const { getByTestId } = render(
<VacationDaysInput
label="Vacation"
value={25}
onChange={onChange}
max={50}
/>
)
const input = getByTestId('vacation-days-field')
fireEvent.change(input, { target: { value: '100' } })
expect(onChange).toHaveBeenCalledWith(50) // max enforced
})
it('displays default range', () => {
const { getByTestId } = render(
<VacationDaysInput label="Vacation" value={25} onChange={() => {}} />
)
expect(getByTestId('vacation-days-input').textContent).toContain('0 – 365')
})
it('displays custom range', () => {
const { getByTestId } = render(
<VacationDaysInput label="Vacation" value={25} onChange={() => {}} min={10} max={50} />
)
expect(getByTestId('vacation-days-input').textContent).toContain('10 – 50')
})
it('handles non-numeric input gracefully', () => {
const onChange = vi.fn()
const { getByTestId } = render(
<VacationDaysInput label="Vacation" value={25} onChange={onChange} />
)
const input = getByTestId('vacation-days-field')
fireEvent.change(input, { target: { value: 'abc' } })
expect(onChange).not.toHaveBeenCalled()
})
})
@@ -0,0 +1,45 @@
import { ReactNode } from 'react'
export interface VacationDaysInputProps {
label: string
value: number
onChange: (val: number) => void
min?: number
max?: number
}
export function VacationDaysInput({
label,
value,
onChange,
min = 0,
max = 365,
}: VacationDaysInputProps): ReactNode {
return (
<div data-testid="vacation-days-input" className="vacation-days-input">
<label htmlFor="vacation-days-field" className="vacation-days-input__label">
{label}
</label>
<input
id="vacation-days-field"
type="number"
min={min}
max={max}
value={value}
onChange={(e) => {
const num = parseInt(e.target.value, 10)
if (!isNaN(num)) {
onChange(Math.max(min, Math.min(num, max)))
}
}}
data-testid="vacation-days-field"
className="vacation-days-input__field"
/>
<div className="vacation-days-input__range">
<small>
{min} – {max}
</small>
</div>
</div>
)
}