Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
82 changes: 57 additions & 25 deletions apps/mobile/components/ui/app-date-picker.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,17 +9,17 @@ import {
import {
addMonths,
subMonths,
addYears,
subYears,
addDays,
startOfMonth,
endOfMonth,
startOfWeek,
endOfWeek,
eachDayOfInterval,
format,
isSameMonth,
isSameDay,
parseISO,
} from 'date-fns'
import { Calendar, ChevronLeft, ChevronRight } from 'lucide-react-native'
import { Calendar, ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight } from 'lucide-react-native'
import { useTranslation } from 'react-i18next'
import { formatLocaleDate } from '@orbit/shared/utils'
import { useProfile } from '@/hooks/use-profile'
Expand Down Expand Up @@ -83,11 +83,8 @@ export function AppDatePicker({
}, [weekStartsOn, t])

const calendarDays = useMemo(() => {
const monthStart = startOfMonth(viewDate)
const monthEnd = endOfMonth(viewDate)
const calStart = startOfWeek(monthStart, { weekStartsOn })
const calEnd = endOfWeek(monthEnd, { weekStartsOn })
return eachDayOfInterval({ start: calStart, end: calEnd })
const calStart = startOfWeek(startOfMonth(viewDate), { weekStartsOn })
return Array.from({ length: 42 }, (_, index) => addDays(calStart, index))
}, [viewDate, weekStartsOn])

const calendarWeeks = useMemo(() => {
Expand All @@ -106,6 +103,14 @@ export function AppDatePicker({
setViewDate((d) => addMonths(d, 1))
}, [])

const prevYear = useCallback(() => {
setViewDate((d) => subYears(d, 1))
}, [])

const nextYear = useCallback(() => {
setViewDate((d) => addYears(d, 1))
}, [])

function selectDay(day: Date) {
onChange(format(day, 'yyyy-MM-dd'))
setIsOpen(false)
Expand Down Expand Up @@ -156,25 +161,47 @@ export function AppDatePicker({
onStartShouldSetResponder={() => true}
>
<View style={styles.monthNav}>
<TouchableOpacity
onPress={prevMonth}
hitSlop={{ top: 8, bottom: 8, left: 8, right: 8 }}
accessibilityRole="button"
accessibilityLabel={t('common.previousMonth')}
>
<ChevronLeft size={18} strokeWidth={1.8} color={tokens.fg3} />
</TouchableOpacity>
<View style={styles.monthNavGroup}>
<TouchableOpacity
onPress={prevYear}
hitSlop={{ top: 8, bottom: 8, left: 6, right: 6 }}
accessibilityRole="button"
accessibilityLabel={t('common.previousYear')}
>
<ChevronsLeft size={18} strokeWidth={1.8} color={tokens.fg3} />
</TouchableOpacity>

<TouchableOpacity
onPress={prevMonth}
hitSlop={{ top: 8, bottom: 8, left: 6, right: 6 }}
accessibilityRole="button"
accessibilityLabel={t('common.previousMonth')}
>
<ChevronLeft size={18} strokeWidth={1.8} color={tokens.fg3} />
</TouchableOpacity>
</View>

<Text style={styles.monthLabel}>{monthLabel}</Text>

<TouchableOpacity
onPress={nextMonth}
hitSlop={{ top: 8, bottom: 8, left: 8, right: 8 }}
accessibilityRole="button"
accessibilityLabel={t('common.nextMonth')}
>
<ChevronRight size={18} strokeWidth={1.8} color={tokens.fg3} />
</TouchableOpacity>
<View style={styles.monthNavGroup}>
<TouchableOpacity
onPress={nextMonth}
hitSlop={{ top: 8, bottom: 8, left: 6, right: 6 }}
accessibilityRole="button"
accessibilityLabel={t('common.nextMonth')}
>
<ChevronRight size={18} strokeWidth={1.8} color={tokens.fg3} />
</TouchableOpacity>

<TouchableOpacity
onPress={nextYear}
hitSlop={{ top: 8, bottom: 8, left: 6, right: 6 }}
accessibilityRole="button"
accessibilityLabel={t('common.nextYear')}
>
<ChevronsRight size={18} strokeWidth={1.8} color={tokens.fg3} />
</TouchableOpacity>
</View>
</View>

<View style={styles.weekRow}>
Expand Down Expand Up @@ -282,6 +309,11 @@ function createStyles(tokens: AppTokens) {
marginBottom: 8,
paddingHorizontal: 4,
},
monthNavGroup: {
flexDirection: 'row',
alignItems: 'center',
gap: 8,
},
monthLabel: {
color: tokens.fg1,
fontFamily: 'Rubik_500Medium',
Expand Down
22 changes: 22 additions & 0 deletions apps/web/__tests__/components/ui/app-date-picker.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -77,4 +77,26 @@ describe('AppDatePicker', () => {
const selected = document.querySelector('[aria-pressed="true"]')
expect(selected).toBeInTheDocument()
})

it('always renders six weeks (42 day cells) regardless of month length', () => {
render(<AppDatePicker value="2025-06-15" onChange={vi.fn()} />)
fireEvent.click(screen.getByRole('button'))
expect(document.querySelectorAll('tbody tr')).toHaveLength(6)
expect(document.querySelectorAll('td button')).toHaveLength(42)
})

it('has previous and next year navigation', () => {
render(<AppDatePicker value="2025-06-15" onChange={vi.fn()} />)
fireEvent.click(screen.getByRole('button'))
expect(screen.getByLabelText('common.previousYear')).toBeInTheDocument()
expect(screen.getByLabelText('common.nextYear')).toBeInTheDocument()
})

it('advances the year when the next-year arrow is clicked', () => {
render(<AppDatePicker value="2025-06-15" onChange={vi.fn()} />)
fireEvent.click(screen.getByRole('button'))
expect(screen.getByText('June 2025')).toBeInTheDocument()
fireEvent.click(screen.getByLabelText('common.nextYear'))
expect(screen.getByText('June 2026')).toBeInTheDocument()
})
})
73 changes: 49 additions & 24 deletions apps/web/components/ui/app-date-picker.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,18 +4,18 @@ import { useState, useId, useRef, useCallback, useMemo, useEffect } from 'react'
import {
addMonths,
subMonths,
addYears,
subYears,
startOfMonth,
endOfMonth,
startOfWeek,
endOfWeek,
eachDayOfInterval,
addDays,
format,
isSameMonth,
isSameDay,
parseISO,
} from 'date-fns'
import { Calendar, ChevronLeft, ChevronRight } from 'lucide-react'
import { Calendar, ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight } from 'lucide-react'
import { useTranslations, useLocale } from 'next-intl'
import { formatLocaleDate } from '@orbit/shared/utils'
import { useProfile } from '@/hooks/use-profile'
Expand Down Expand Up @@ -67,11 +67,8 @@ export function AppDatePicker({
}, [weekStartsOn, t])

const calendarDays = useMemo(() => {
const monthStart = startOfMonth(viewDate)
const monthEnd = endOfMonth(viewDate)
const calStart = startOfWeek(monthStart, { weekStartsOn })
const calEnd = endOfWeek(monthEnd, { weekStartsOn })
return eachDayOfInterval({ start: calStart, end: calEnd })
const calStart = startOfWeek(startOfMonth(viewDate), { weekStartsOn })
return Array.from({ length: 42 }, (_, index) => addDays(calStart, index))
}, [viewDate, weekStartsOn])

const calendarWeeks = (() => {
Expand All @@ -98,6 +95,14 @@ export function AppDatePicker({
setViewDate((d) => addMonths(d, 1))
}

function prevYear() {
setViewDate((d) => subYears(d, 1))
}

function nextYear() {
setViewDate((d) => addYears(d, 1))
}

const closePicker = useCallback(() => {
setIsOpen(false)
setFocusedDate(null)
Expand Down Expand Up @@ -193,29 +198,49 @@ export function AppDatePicker({
className="fixed z-50 left-1/2 top-1/2 m-0 w-[min(90vw,320px)] -translate-x-1/2 -translate-y-1/2 rounded-[16px] border-0 bg-[var(--bg-sheet)] p-2.5 text-[var(--fg-1)] shadow-[var(--shadow-2),inset_0_0_0_1px_var(--hairline)]"
>
<div className="flex items-center justify-between mb-2">
<button
type="button"
className="p-1.5 rounded-lg hover:bg-[var(--bg-elev)]"
aria-label={t('common.previousMonth')}
onClick={prevMonth}
>
<ChevronLeft size={18} strokeWidth={1.8} className="text-[var(--fg-3)]" />
</button>
<div className="flex items-center gap-0.5">
<button
type="button"
className="p-1.5 rounded-lg hover:bg-[var(--bg-elev)]"
aria-label={t('common.previousYear')}
onClick={prevYear}
>
<ChevronsLeft size={18} strokeWidth={1.8} className="text-[var(--fg-3)]" />
</button>
<button
type="button"
className="p-1.5 rounded-lg hover:bg-[var(--bg-elev)]"
aria-label={t('common.previousMonth')}
onClick={prevMonth}
>
<ChevronLeft size={18} strokeWidth={1.8} className="text-[var(--fg-3)]" />
</button>
</div>
<span
id={dialogLabelId}
className="text-xs font-medium text-[var(--fg-1)] capitalize"
aria-live="polite"
>
{monthLabel}
</span>
<button
type="button"
className="p-1.5 rounded-lg hover:bg-[var(--bg-elev)]"
aria-label={t('common.nextMonth')}
onClick={nextMonth}
>
<ChevronRight size={18} strokeWidth={1.8} className="text-[var(--fg-3)]" />
</button>
<div className="flex items-center gap-0.5">
<button
type="button"
className="p-1.5 rounded-lg hover:bg-[var(--bg-elev)]"
aria-label={t('common.nextMonth')}
onClick={nextMonth}
>
<ChevronRight size={18} strokeWidth={1.8} className="text-[var(--fg-3)]" />
</button>
<button
type="button"
className="p-1.5 rounded-lg hover:bg-[var(--bg-elev)]"
aria-label={t('common.nextYear')}
onClick={nextYear}
>
<ChevronsRight size={18} strokeWidth={1.8} className="text-[var(--fg-3)]" />
</button>
</div>
</div>

<table
Expand Down
2 changes: 2 additions & 0 deletions packages/shared/src/i18n/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,8 @@
"amPm": "AM/PM",
"previousMonth": "Previous month",
"nextMonth": "Next month",
"previousYear": "Previous year",
"nextYear": "Next year",
"continue": "Continue",
"unknown": "Unknown",
"failed": "Failed",
Expand Down
2 changes: 2 additions & 0 deletions packages/shared/src/i18n/pt-BR.json
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,8 @@
"amPm": "AM/PM",
"previousMonth": "Mês anterior",
"nextMonth": "Próximo mês",
"previousYear": "Ano anterior",
"nextYear": "Próximo ano",
"continue": "Continuar",
"unknown": "Desconhecido",
"failed": "Falhou",
Expand Down