Let the calendar jump to any month or year, in both directions
Clicking the month or year label only ever stepped forward (`month + 1`, `year + 1`), so the only way back was the prev-month arrow, one month at a time. Jumping back a year was impossible. Both labels are now selects, so any month or year is one click away in either direction. Selects rather than +/- steppers because the app already uses them elsewhere, and because stepping in a second direction would still not give the "quickly jump" part of the original intent. Year options are bounded to 2026-2055, the calendar cache range: outside it the backend raises UncachedYearError and returns 422, so offering those years would hand the user an error instead of a calendar. The prev/next month arrows are unchanged. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RXMhc9nT6sNz1SL4j8dznL
This commit is contained in:
@@ -1,6 +1,8 @@
|
||||
export const calendar = {
|
||||
'calendar.advanceMonthAria': '{month} - click to advance month',
|
||||
'calendar.advanceYearAria': '{year} - click to advance year',
|
||||
'calendar.selectMonthAria': 'Select month',
|
||||
'calendar.selectYearAria': 'Select year',
|
||||
'calendar.deleteError': 'Failed to delete the planned day',
|
||||
'calendar.legendDoubleDay': 'Double day',
|
||||
'calendar.legendIncomeBased': 'Income-based rate (sjukpenningnivå)',
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
export const calendar = {
|
||||
'calendar.advanceMonthAria': '{month} - klicka för att byta månad',
|
||||
'calendar.advanceYearAria': '{year} - klicka för att byta år',
|
||||
'calendar.selectMonthAria': 'Välj månad',
|
||||
'calendar.selectYearAria': 'Välj år',
|
||||
'calendar.deleteError': 'Det gick inte att ta bort den planerade dagen',
|
||||
'calendar.legendDoubleDay': 'Dubbeldag',
|
||||
'calendar.legendIncomeBased': 'Sjukpenningnivå',
|
||||
|
||||
@@ -206,86 +206,104 @@ describe('Calendar', () => {
|
||||
expect(selectedDaysInRange.length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('advances one month when clicking the month button', () => {
|
||||
it('jumps forward to any month via month dropdown', () => {
|
||||
;(useWorkspace as any).mockReturnValue(mockWorkspace)
|
||||
;(usePlannedDays as any).mockReturnValue(mockPlannedDays)
|
||||
|
||||
render(<Calendar />)
|
||||
|
||||
const title = screen.getByRole('heading', { level: 2 })
|
||||
const monthButton = title.querySelector('.calendar__month-button') as HTMLButtonElement
|
||||
const monthSelect = title.querySelector('.calendar__month-select') as HTMLSelectElement
|
||||
|
||||
expect(monthButton).toBeInTheDocument()
|
||||
expect(monthSelect).toBeInTheDocument()
|
||||
|
||||
// Get initial month/year from title
|
||||
const initialText = title.textContent
|
||||
// Change to a different month (e.g., month 5 = June, 0-indexed)
|
||||
fireEvent.change(monthSelect, { target: { value: '5' } })
|
||||
|
||||
// Click the month button to advance one month
|
||||
fireEvent.click(monthButton)
|
||||
|
||||
// The month should have changed (we can't predict the exact month without knowing current date,
|
||||
// but we can verify the text changed)
|
||||
const updatedText = title.textContent
|
||||
expect(updatedText).not.toBe(initialText)
|
||||
// Verify the select value changed
|
||||
expect(monthSelect.value).toBe('5')
|
||||
})
|
||||
|
||||
it('advances one year when clicking the year button', () => {
|
||||
it('jumps backward to any month via month dropdown', () => {
|
||||
;(useWorkspace as any).mockReturnValue(mockWorkspace)
|
||||
;(usePlannedDays as any).mockReturnValue(mockPlannedDays)
|
||||
|
||||
render(<Calendar />)
|
||||
|
||||
const title = screen.getByRole('heading', { level: 2 })
|
||||
const yearButton = title.querySelector('.calendar__year-button') as HTMLButtonElement
|
||||
const monthSelect = title.querySelector('.calendar__month-select') as HTMLSelectElement
|
||||
|
||||
expect(yearButton).toBeInTheDocument()
|
||||
expect(monthSelect).toBeInTheDocument()
|
||||
|
||||
// Get initial year
|
||||
const initialYear = parseInt(yearButton.textContent || '0')
|
||||
// Jump backward to January (month 0)
|
||||
fireEvent.change(monthSelect, { target: { value: '0' } })
|
||||
|
||||
// Click the year button to advance one year
|
||||
fireEvent.click(yearButton)
|
||||
|
||||
// The year should have increased by 1
|
||||
const updatedYearButton = screen.getByRole('heading', { level: 2 }).querySelector('.calendar__year-button') as HTMLButtonElement
|
||||
const updatedYear = parseInt(updatedYearButton.textContent || '0')
|
||||
expect(updatedYear).toBe(initialYear + 1)
|
||||
expect(monthSelect.value).toBe('0')
|
||||
})
|
||||
|
||||
it('handles December to January rollover when clicking month button in December', () => {
|
||||
it('jumps forward to any year via year dropdown', () => {
|
||||
;(useWorkspace as any).mockReturnValue(mockWorkspace)
|
||||
;(usePlannedDays as any).mockReturnValue(mockPlannedDays)
|
||||
|
||||
// Mock a December render - we need to test the logic, not the actual calendar state
|
||||
render(<Calendar />)
|
||||
|
||||
const title = screen.getByRole('heading', { level: 2 })
|
||||
const monthButton = title.querySelector('.calendar__month-button') as HTMLButtonElement
|
||||
const yearSelect = title.querySelector('.calendar__year-select') as HTMLSelectElement
|
||||
|
||||
// Click month button 12 times (a full year cycle)
|
||||
for (let i = 0; i < 12; i++) {
|
||||
fireEvent.click(monthButton)
|
||||
}
|
||||
expect(yearSelect).toBeInTheDocument()
|
||||
|
||||
// The year might have incremented depending on when we started, but the calendar should still work
|
||||
const finalTitle = screen.getByRole('heading', { level: 2 })
|
||||
expect(finalTitle).toBeInTheDocument()
|
||||
// Jump to year 2035
|
||||
fireEvent.change(yearSelect, { target: { value: '2035' } })
|
||||
|
||||
expect(yearSelect.value).toBe('2035')
|
||||
})
|
||||
|
||||
it('month and year buttons have proper accessibility labels', () => {
|
||||
it('jumps backward to any year via year dropdown', () => {
|
||||
;(useWorkspace as any).mockReturnValue(mockWorkspace)
|
||||
;(usePlannedDays as any).mockReturnValue(mockPlannedDays)
|
||||
|
||||
render(<Calendar />)
|
||||
|
||||
const title = screen.getByRole('heading', { level: 2 })
|
||||
const monthButton = title.querySelector('.calendar__month-button') as HTMLButtonElement
|
||||
const yearButton = title.querySelector('.calendar__year-button') as HTMLButtonElement
|
||||
const yearSelect = title.querySelector('.calendar__year-select') as HTMLSelectElement
|
||||
|
||||
expect(monthButton).toHaveAttribute('aria-label')
|
||||
expect(yearButton).toHaveAttribute('aria-label')
|
||||
expect(monthButton.getAttribute('aria-label')).toContain('klicka för att byta månad')
|
||||
expect(yearButton.getAttribute('aria-label')).toContain('klicka för att byta år')
|
||||
expect(yearSelect).toBeInTheDocument()
|
||||
|
||||
// Jump backward to year 2026 (the minimum supported year)
|
||||
fireEvent.change(yearSelect, { target: { value: '2026' } })
|
||||
|
||||
expect(yearSelect.value).toBe('2026')
|
||||
})
|
||||
|
||||
it('year dropdown includes all supported years from 2026 to 2055', () => {
|
||||
;(useWorkspace as any).mockReturnValue(mockWorkspace)
|
||||
;(usePlannedDays as any).mockReturnValue(mockPlannedDays)
|
||||
|
||||
render(<Calendar />)
|
||||
|
||||
const title = screen.getByRole('heading', { level: 2 })
|
||||
const yearSelect = title.querySelector('.calendar__year-select') as HTMLSelectElement
|
||||
|
||||
const options = Array.from(yearSelect.options).map((opt) => parseInt(opt.value))
|
||||
|
||||
// Verify the range is correct
|
||||
expect(Math.min(...options)).toBe(2026)
|
||||
expect(Math.max(...options)).toBe(2055)
|
||||
expect(options).toHaveLength(30) // 2026 to 2055 inclusive is 30 years
|
||||
})
|
||||
|
||||
it('month and year selects have proper accessibility labels', () => {
|
||||
;(useWorkspace as any).mockReturnValue(mockWorkspace)
|
||||
;(usePlannedDays as any).mockReturnValue(mockPlannedDays)
|
||||
|
||||
render(<Calendar />)
|
||||
|
||||
const title = screen.getByRole('heading', { level: 2 })
|
||||
const monthSelect = title.querySelector('.calendar__month-select') as HTMLSelectElement
|
||||
const yearSelect = title.querySelector('.calendar__year-select') as HTMLSelectElement
|
||||
|
||||
expect(monthSelect).toHaveAttribute('aria-label', 'Välj månad')
|
||||
expect(yearSelect).toHaveAttribute('aria-label', 'Välj år')
|
||||
})
|
||||
|
||||
// §3.4 shading + §3.6 click-to-override
|
||||
@@ -588,9 +606,9 @@ describe('Calendar', () => {
|
||||
</LanguageProvider>,
|
||||
)
|
||||
const title = screen.getByRole('heading', { level: 2 })
|
||||
const monthButton = title.querySelector('.calendar__month-button') as HTMLButtonElement
|
||||
// English month names differ from the Swedish ones (e.g. "January" vs "januari").
|
||||
expect(monthButton.getAttribute('aria-label')).toContain('click to advance month')
|
||||
const monthSelect = title.querySelector('.calendar__month-select') as HTMLSelectElement
|
||||
// Month select has a simple aria label
|
||||
expect(monthSelect.getAttribute('aria-label')).toBe('Select month')
|
||||
} finally {
|
||||
Object.defineProperty(window.navigator, 'language', { value: 'sv-SE', configurable: true })
|
||||
vi.unstubAllGlobals()
|
||||
|
||||
@@ -137,12 +137,14 @@ export function Calendar(): ReactNode {
|
||||
setCurrentMonth(new Date(year, month + 1, 1))
|
||||
}
|
||||
|
||||
const handleMonthClick = () => {
|
||||
setCurrentMonth(new Date(year, month + 1, 1))
|
||||
const handleMonthChange = (e: React.ChangeEvent<HTMLSelectElement>) => {
|
||||
const newMonth = Number(e.target.value)
|
||||
setCurrentMonth(new Date(year, newMonth, 1))
|
||||
}
|
||||
|
||||
const handleYearClick = () => {
|
||||
setCurrentMonth(new Date(year + 1, month, 1))
|
||||
const handleYearChange = (e: React.ChangeEvent<HTMLSelectElement>) => {
|
||||
const newYear = Number(e.target.value)
|
||||
setCurrentMonth(new Date(newYear, month, 1))
|
||||
}
|
||||
|
||||
// §3.6 — an explicit mode toggle (rather than a modifier-click or context
|
||||
@@ -299,26 +301,34 @@ export function Calendar(): ReactNode {
|
||||
←
|
||||
</button>
|
||||
<h2 className="calendar__title">
|
||||
<button
|
||||
onClick={handleMonthClick}
|
||||
className="calendar__month-button"
|
||||
aria-label={t('calendar.advanceMonthAria', {
|
||||
month: currentMonth.toLocaleDateString(locale === 'sv' ? 'sv-SE' : 'en-US', { month: 'long' }),
|
||||
})}
|
||||
<select
|
||||
value={month}
|
||||
onChange={handleMonthChange}
|
||||
className="calendar__month-select"
|
||||
aria-label={t('calendar.selectMonthAria')}
|
||||
>
|
||||
{currentMonth.toLocaleDateString(locale === 'sv' ? 'sv-SE' : 'en-US', {
|
||||
month: 'long',
|
||||
})}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleYearClick}
|
||||
className="calendar__year-button"
|
||||
aria-label={t('calendar.advanceYearAria', {
|
||||
year: currentMonth.getFullYear(),
|
||||
{[0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11].map((m) => {
|
||||
const date = new Date(year, m, 1)
|
||||
const monthName = date.toLocaleDateString(locale === 'sv' ? 'sv-SE' : 'en-US', { month: 'long' })
|
||||
return (
|
||||
<option key={m} value={m}>
|
||||
{monthName}
|
||||
</option>
|
||||
)
|
||||
})}
|
||||
</select>
|
||||
<select
|
||||
value={year}
|
||||
onChange={handleYearChange}
|
||||
className="calendar__year-select"
|
||||
aria-label={t('calendar.selectYearAria')}
|
||||
>
|
||||
{currentMonth.getFullYear()}
|
||||
</button>
|
||||
{Array.from({ length: 2055 - 2026 + 1 }, (_, i) => 2026 + i).map((y) => (
|
||||
<option key={y} value={y}>
|
||||
{y}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</h2>
|
||||
<button onClick={handleNextMonth} aria-label={t('calendar.nextMonthAria')}>
|
||||
→
|
||||
|
||||
@@ -38,33 +38,41 @@
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.calendar__month-button,
|
||||
.calendar__year-button {
|
||||
background: none;
|
||||
border: none;
|
||||
.calendar__month-select,
|
||||
.calendar__year-select {
|
||||
background: #fff;
|
||||
border: 1px solid #d0d0d0;
|
||||
color: #333;
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
padding: 4px 8px;
|
||||
border-radius: 4px;
|
||||
transition: background-color 0.2s, color 0.2s;
|
||||
transition: background-color 0.2s, border-color 0.2s, color 0.2s;
|
||||
appearance: none;
|
||||
padding-right: 28px;
|
||||
background-image: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 12 8"><path d="M1 1l5 5 5-5" stroke="%23333" stroke-width="1.5" fill="none" stroke-linecap="round"/></svg>');
|
||||
background-repeat: no-repeat;
|
||||
background-position: right 6px center;
|
||||
background-size: 12px;
|
||||
}
|
||||
|
||||
.calendar__month-button:hover,
|
||||
.calendar__year-button:hover {
|
||||
.calendar__month-select:hover,
|
||||
.calendar__year-select:hover {
|
||||
background-color: #f0f0f0;
|
||||
border-color: #0066cc;
|
||||
color: #0066cc;
|
||||
}
|
||||
|
||||
.calendar__month-button:focus-visible,
|
||||
.calendar__year-button:focus-visible {
|
||||
.calendar__month-select:focus-visible,
|
||||
.calendar__year-select:focus-visible {
|
||||
outline: 2px solid #0066cc;
|
||||
outline-offset: 2px;
|
||||
border-color: #0066cc;
|
||||
}
|
||||
|
||||
.calendar__month-button:active,
|
||||
.calendar__year-button:active {
|
||||
.calendar__month-select:active,
|
||||
.calendar__year-select:active {
|
||||
background-color: #e0e0e0;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user