mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
feat(journey): let the photo picker's calendar jump years (#2318)
The date range on the photo picker had a calendar that moved one month per click and no other way to move. A trip three years back was thirty-six clicks away. The header is a button now, the way the app's other calendar works: click the month to see the year's months, click the year to see a page of twelve years, and the arrows step whichever level is showing. A pick steps back down, and reopening starts on the days again. The same component serves the entry editor, which keeps its trip-day dots.
This commit is contained in:
@@ -0,0 +1,101 @@
|
||||
// FE-COMP-JOURNEYDATEPICKER-001 to FE-COMP-JOURNEYDATEPICKER-006
|
||||
import React from 'react'
|
||||
import { describe, it, expect, vi } from 'vitest'
|
||||
import { render, screen } from '../../../tests/helpers/render'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { DatePicker } from './JourneyDetailPageDatePicker'
|
||||
|
||||
function open(value = '2026-09-11') {
|
||||
const onChange = vi.fn()
|
||||
render(<DatePicker value={value} onChange={onChange} />)
|
||||
return { onChange }
|
||||
}
|
||||
|
||||
const header = () => screen.getByRole('button', { name: /select month|select year|\d{4} – \d{4}/i })
|
||||
const prev = (name: RegExp) => screen.getByRole('button', { name })
|
||||
|
||||
describe('DatePicker year navigation (#2318)', () => {
|
||||
it('FE-COMP-JOURNEYDATEPICKER-001: opens on the month of the value with weekday headers', async () => {
|
||||
const user = userEvent.setup()
|
||||
open()
|
||||
await user.click(screen.getByText(/Sep 11, 2026/))
|
||||
expect(screen.getByText('Mo')).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: 'Select month' })).toHaveTextContent('September 2026')
|
||||
})
|
||||
|
||||
it('FE-COMP-JOURNEYDATEPICKER-002: the header climbs to the months, then to the years', async () => {
|
||||
const user = userEvent.setup()
|
||||
open()
|
||||
await user.click(screen.getByText(/Sep 11, 2026/))
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Select month' }))
|
||||
expect(screen.queryByText('Mo')).not.toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: 'Jan' })).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: 'Select year' })).toHaveTextContent('2026')
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Select year' }))
|
||||
expect(screen.getByRole('button', { name: '2016' })).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: '2027' })).toBeInTheDocument()
|
||||
expect(header()).toHaveTextContent('2016 – 2027')
|
||||
})
|
||||
|
||||
it('FE-COMP-JOURNEYDATEPICKER-003: picking a year three back lands on that year in one move', async () => {
|
||||
const user = userEvent.setup()
|
||||
open()
|
||||
await user.click(screen.getByText(/Sep 11, 2026/))
|
||||
await user.click(screen.getByRole('button', { name: 'Select month' }))
|
||||
await user.click(screen.getByRole('button', { name: 'Select year' }))
|
||||
await user.click(screen.getByRole('button', { name: '2023' }))
|
||||
|
||||
// Back on the months, of the chosen year.
|
||||
expect(screen.getByRole('button', { name: 'Select year' })).toHaveTextContent('2023')
|
||||
await user.click(screen.getByRole('button', { name: 'Mar' }))
|
||||
expect(screen.getByRole('button', { name: 'Select month' })).toHaveTextContent('March 2023')
|
||||
expect(screen.getByText('Mo')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-COMP-JOURNEYDATEPICKER-004: the arrows step the level they are on', async () => {
|
||||
const user = userEvent.setup()
|
||||
open()
|
||||
await user.click(screen.getByText(/Sep 11, 2026/))
|
||||
|
||||
await user.click(prev(/previous month/i))
|
||||
expect(screen.getByRole('button', { name: 'Select month' })).toHaveTextContent('August 2026')
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Select month' }))
|
||||
await user.click(prev(/previous year/i))
|
||||
expect(screen.getByRole('button', { name: 'Select year' })).toHaveTextContent('2025')
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Select year' }))
|
||||
await user.click(prev(/previous years/i))
|
||||
expect(header()).toHaveTextContent('2004 – 2015')
|
||||
await user.click(screen.getByRole('button', { name: /next years/i }))
|
||||
expect(header()).toHaveTextContent('2016 – 2027')
|
||||
})
|
||||
|
||||
it('FE-COMP-JOURNEYDATEPICKER-005: a day picked after the jump is handed back as ISO', async () => {
|
||||
const user = userEvent.setup()
|
||||
const { onChange } = open()
|
||||
await user.click(screen.getByText(/Sep 11, 2026/))
|
||||
await user.click(screen.getByRole('button', { name: 'Select month' }))
|
||||
await user.click(screen.getByRole('button', { name: 'Select year' }))
|
||||
await user.click(screen.getByRole('button', { name: '2023' }))
|
||||
await user.click(screen.getByRole('button', { name: 'Mar' }))
|
||||
await user.click(screen.getByRole('button', { name: '15' }))
|
||||
expect(onChange).toHaveBeenCalledWith('2023-03-15')
|
||||
expect(screen.queryByText('Mo')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-COMP-JOURNEYDATEPICKER-006: reopening starts on the days again', async () => {
|
||||
const user = userEvent.setup()
|
||||
open()
|
||||
const trigger = screen.getByText(/Sep 11, 2026/)
|
||||
await user.click(trigger)
|
||||
await user.click(screen.getByRole('button', { name: 'Select month' }))
|
||||
await user.click(screen.getByRole('button', { name: 'Select year' }))
|
||||
await user.click(trigger)
|
||||
expect(screen.queryByRole('button', { name: '2023' })).not.toBeInTheDocument()
|
||||
await user.click(trigger)
|
||||
expect(screen.getByText('Mo')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -3,6 +3,16 @@ import { localIsoDate } from '../../utils/localDate'
|
||||
import { ArrowLeft, ChevronRight, Calendar } from 'lucide-react'
|
||||
import { useTranslation } from '../../i18n'
|
||||
|
||||
/**
|
||||
* The header steps up a level on each click, days → months → years, and a
|
||||
* pick steps back down. Mirrors CustomDateTimePicker, so a person who has
|
||||
* learnt one calendar in TREK knows the other. Without it the only way to a
|
||||
* different year was one month at a time, which on a photo picker asked to
|
||||
* cover a trip three years back meant thirty-six clicks (#2318).
|
||||
*/
|
||||
type CalendarView = 'days' | 'months' | 'years'
|
||||
const YEAR_PAGE_SIZE = 12
|
||||
|
||||
export function DatePicker({ value, onChange, tripDates }: {
|
||||
value: string
|
||||
onChange: (date: string) => void
|
||||
@@ -10,6 +20,8 @@ export function DatePicker({ value, onChange, tripDates }: {
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
const [open, setOpen] = useState(false)
|
||||
const [view, setView] = useState<CalendarView>('days')
|
||||
const [yearPageStart, setYearPageStart] = useState(0)
|
||||
const [viewMonth, setViewMonth] = useState(() => {
|
||||
const d = value ? new Date(value + 'T00:00:00') : new Date()
|
||||
return { year: d.getFullYear(), month: d.getMonth() }
|
||||
@@ -27,19 +39,73 @@ export function DatePicker({ value, onChange, tripDates }: {
|
||||
setViewMonth(p => p.month === 11 ? { year: p.year + 1, month: 0 } : { ...p, month: p.month + 1 })
|
||||
}
|
||||
|
||||
const handlePrev = () => {
|
||||
if (view === 'days') prevMonth()
|
||||
else if (view === 'months') setViewMonth(p => ({ ...p, year: p.year - 1 }))
|
||||
else setYearPageStart(s => s - YEAR_PAGE_SIZE)
|
||||
}
|
||||
const handleNext = () => {
|
||||
if (view === 'days') nextMonth()
|
||||
else if (view === 'months') setViewMonth(p => ({ ...p, year: p.year + 1 }))
|
||||
else setYearPageStart(s => s + YEAR_PAGE_SIZE)
|
||||
}
|
||||
const handleHeaderClick = () => {
|
||||
if (view === 'days') {
|
||||
setView('months')
|
||||
} else if (view === 'months') {
|
||||
setYearPageStart(Math.floor(viewMonth.year / YEAR_PAGE_SIZE) * YEAR_PAGE_SIZE)
|
||||
setView('years')
|
||||
}
|
||||
}
|
||||
const selectMonth = (month: number) => {
|
||||
setViewMonth(p => ({ ...p, month }))
|
||||
setView('days')
|
||||
}
|
||||
const selectYear = (year: number) => {
|
||||
setViewMonth(p => ({ ...p, year }))
|
||||
setView('months')
|
||||
}
|
||||
const toggleOpen = () => {
|
||||
// Reopening lands on the days again: a picker left on the year grid
|
||||
// would otherwise open there next time, one level away from a date.
|
||||
setView('days')
|
||||
setOpen(!open)
|
||||
}
|
||||
|
||||
const prevLabel = view === 'days' ? t('common.datepicker.prevMonth')
|
||||
: view === 'months' ? t('common.datepicker.prevYear') : t('common.datepicker.prevYears')
|
||||
const nextLabel = view === 'days' ? t('common.datepicker.nextMonth')
|
||||
: view === 'months' ? t('common.datepicker.nextYear') : t('common.datepicker.nextYears')
|
||||
const headerLabel = view === 'days' ? monthName
|
||||
: view === 'months' ? String(viewMonth.year)
|
||||
: `${yearPageStart} – ${yearPageStart + YEAR_PAGE_SIZE - 1}`
|
||||
const headerAria = view === 'days' ? t('common.datepicker.selectMonth')
|
||||
: view === 'months' ? t('common.datepicker.selectYear') : undefined
|
||||
|
||||
const pad = (n: number) => String(n).padStart(2, '0')
|
||||
|
||||
const cells: (number | null)[] = []
|
||||
for (let i = 0; i < firstDow; i++) cells.push(null)
|
||||
for (let d = 1; d <= daysInMonth; d++) cells.push(d)
|
||||
|
||||
const monthNames = Array.from({ length: 12 }, (_, i) =>
|
||||
new Date(viewMonth.year, i).toLocaleDateString(undefined, { month: 'short' }))
|
||||
const years = Array.from({ length: YEAR_PAGE_SIZE }, (_, i) => yearPageStart + i)
|
||||
|
||||
const formatted = value ? new Date(value + 'T00:00:00').toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' }) : null
|
||||
|
||||
const navButton = 'w-7 h-7 rounded-lg hover:bg-zinc-100 dark:hover:bg-zinc-700 flex items-center justify-center text-zinc-500'
|
||||
const gridCell = (selected: boolean) => `h-9 rounded-lg text-[12px] font-medium flex items-center justify-center transition-colors ${
|
||||
selected
|
||||
? 'bg-zinc-900 dark:bg-white text-white dark:text-zinc-900'
|
||||
: 'text-zinc-700 dark:text-zinc-300 hover:bg-zinc-100 dark:hover:bg-zinc-700'
|
||||
}`
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen(!open)}
|
||||
onClick={toggleOpen}
|
||||
className="w-full px-3 py-2 border border-zinc-200 dark:border-zinc-700 rounded-lg text-[13px] bg-white dark:bg-zinc-800 text-zinc-900 dark:text-white text-left flex items-center justify-between"
|
||||
>
|
||||
{formatted ? (
|
||||
@@ -59,54 +125,86 @@ export function DatePicker({ value, onChange, tripDates }: {
|
||||
above closes the popover again from the keyboard. */}
|
||||
<div role="presentation" className="fixed inset-0 z-[10]" onClick={() => setOpen(false)} />
|
||||
<div className="absolute top-full left-0 mt-1 z-[20] bg-white dark:bg-zinc-800 border border-zinc-200 dark:border-zinc-700 rounded-xl shadow-lg p-3 w-[280px]">
|
||||
{/* Month nav */}
|
||||
{/* Header: arrows step the current view, the label climbs to the next one up */}
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<button type="button" onClick={prevMonth} className="w-7 h-7 rounded-lg hover:bg-zinc-100 dark:hover:bg-zinc-700 flex items-center justify-center text-zinc-500">
|
||||
<button type="button" onClick={handlePrev} aria-label={prevLabel} className={navButton}>
|
||||
<ArrowLeft size={14} />
|
||||
</button>
|
||||
<span className="text-[13px] font-semibold text-zinc-900 dark:text-white">{monthName}</span>
|
||||
<button type="button" onClick={nextMonth} className="w-7 h-7 rounded-lg hover:bg-zinc-100 dark:hover:bg-zinc-700 flex items-center justify-center text-zinc-500">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleHeaderClick}
|
||||
aria-label={headerAria}
|
||||
disabled={view === 'years'}
|
||||
className="px-2 py-1 rounded-lg text-[13px] font-semibold text-zinc-900 dark:text-white hover:bg-zinc-100 dark:hover:bg-zinc-700 disabled:hover:bg-transparent disabled:cursor-default"
|
||||
>
|
||||
{headerLabel}
|
||||
</button>
|
||||
<button type="button" onClick={handleNext} aria-label={nextLabel} className={navButton}>
|
||||
<ChevronRight size={14} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Weekday headers */}
|
||||
<div className="grid grid-cols-7 mb-1">
|
||||
{['Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa', 'Su'].map((d, i) => (
|
||||
<div key={i} className="text-center text-[10px] font-medium text-zinc-400 py-1">{d}</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Day grid */}
|
||||
<div className="grid grid-cols-7">
|
||||
{cells.map((day, i) => {
|
||||
if (day === null) return <div key={`e${i}`} />
|
||||
const dateStr = `${viewMonth.year}-${pad(viewMonth.month + 1)}-${pad(day)}`
|
||||
const isSelected = dateStr === value
|
||||
const isTrip = tripDates?.has(dateStr)
|
||||
const isToday = dateStr === localIsoDate()
|
||||
|
||||
return (
|
||||
<button
|
||||
key={dateStr}
|
||||
type="button"
|
||||
onClick={() => { onChange(dateStr); setOpen(false) }}
|
||||
className={`w-9 h-9 rounded-lg text-[12px] font-medium flex items-center justify-center relative transition-colors ${
|
||||
isSelected
|
||||
? 'bg-zinc-900 dark:bg-white text-white dark:text-zinc-900'
|
||||
: isToday
|
||||
? 'text-zinc-900 dark:text-white font-bold'
|
||||
: 'text-zinc-700 dark:text-zinc-300 hover:bg-zinc-100 dark:hover:bg-zinc-700'
|
||||
}`}
|
||||
>
|
||||
{day}
|
||||
{isTrip && !isSelected && (
|
||||
<span className="absolute bottom-1 left-1/2 -translate-x-1/2 w-1 h-1 rounded-full bg-indigo-500" />
|
||||
)}
|
||||
{view === 'months' && (
|
||||
<div className="grid grid-cols-3 gap-1">
|
||||
{monthNames.map((name, i) => (
|
||||
<button key={name} type="button" onClick={() => selectMonth(i)} className={gridCell(i === viewMonth.month)}>
|
||||
{name}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{view === 'years' && (
|
||||
<div className="grid grid-cols-3 gap-1">
|
||||
{years.map(y => (
|
||||
<button key={y} type="button" onClick={() => selectYear(y)} className={gridCell(y === viewMonth.year)}>
|
||||
{y}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{view === 'days' && (
|
||||
<>
|
||||
{/* Weekday headers */}
|
||||
<div className="grid grid-cols-7 mb-1">
|
||||
{['Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa', 'Su'].map((d, i) => (
|
||||
<div key={i} className="text-center text-[10px] font-medium text-zinc-400 py-1">{d}</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Day grid */}
|
||||
<div className="grid grid-cols-7">
|
||||
{cells.map((day, i) => {
|
||||
if (day === null) return <div key={`e${i}`} />
|
||||
const dateStr = `${viewMonth.year}-${pad(viewMonth.month + 1)}-${pad(day)}`
|
||||
const isSelected = dateStr === value
|
||||
const isTrip = tripDates?.has(dateStr)
|
||||
const isToday = dateStr === localIsoDate()
|
||||
|
||||
return (
|
||||
<button
|
||||
key={dateStr}
|
||||
type="button"
|
||||
onClick={() => { onChange(dateStr); setOpen(false) }}
|
||||
className={`w-9 h-9 rounded-lg text-[12px] font-medium flex items-center justify-center relative transition-colors ${
|
||||
isSelected
|
||||
? 'bg-zinc-900 dark:bg-white text-white dark:text-zinc-900'
|
||||
: isToday
|
||||
? 'text-zinc-900 dark:text-white font-bold'
|
||||
: 'text-zinc-700 dark:text-zinc-300 hover:bg-zinc-100 dark:hover:bg-zinc-700'
|
||||
}`}
|
||||
>
|
||||
{day}
|
||||
{isTrip && !isSelected && (
|
||||
<span className="absolute bottom-1 left-1/2 -translate-x-1/2 w-1 h-1 rounded-full bg-indigo-500" />
|
||||
)}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user