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