import dayjs from 'dayjs'; import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'; import * as styles from './calendar.css'; import { DATE_MAX, DATE_MIN } from './constants'; import { CalendarLayout, NavButtons } from './items'; import type { DatePickerModePanelProps } from './types'; const ROW_SIZE = 3; export const MonthPicker = memo(function MonthPicker( props: DatePickerModePanelProps ) { const { cursor, value, monthNames, onModeChange, onCursorChange } = props; const dayPickerRootRef = useRef<HTMLDivElement>(null); const [monthCursor, setMonthCursor] = useState(cursor.startOf('month')); const closeMonthPicker = useCallback( () => onModeChange?.('day'), [onModeChange] ); const onMonthChange = useCallback( (m: dayjs.Dayjs) => { onModeChange?.('day'); onCursorChange?.(m); }, [onCursorChange, onModeChange] ); const nextYear = useCallback( () => setMonthCursor(prev => prev.add(1, 'year').startOf('year')), [] ); const prevYear = useCallback( () => setMonthCursor(prev => prev.subtract(1, 'year').startOf('year')), [] ); const nextYearDisabled = useMemo( () => monthCursor.isSame(DATE_MAX, 'year'), [monthCursor] ); const prevYearDisabled = useMemo( () => monthCursor.isSame(DATE_MIN, 'year'), [monthCursor] ); const matrix = useMemo(() => { const matrix = []; let currentMonth = monthCursor.startOf('year'); while (currentMonth.isBefore(monthCursor.endOf('year'))) { const month: DatePickerModePanelProps['cursor'][] = []; for (let i = 0; i < ROW_SIZE; i++) { month.push(currentMonth.clone()); currentMonth = currentMonth.add(1, 'month'); } matrix.push(month); } return matrix; }, [monthCursor]); const focusCursor = useCallback(() => { const div = dayPickerRootRef.current; if (!div) return; const focused = div.querySelector('[data-is-month-cell][tabindex="0"]'); focused && (focused as HTMLElement).focus(); }, []); // keyboard navigation useEffect(() => { const div = dayPickerRootRef.current; if (!div) return; const onKeyDown = (e: KeyboardEvent) => { if (e.key === 'Escape') { e.preventDefault(); e.stopPropagation(); closeMonthPicker(); return; } if (!['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight'].includes(e.key)) return; e.preventDefault(); e.stopPropagation(); if (e.key === 'ArrowUp') setMonthCursor(c => c.subtract(ROW_SIZE, 'month')); if (e.key === 'ArrowDown') setMonthCursor(c => c.add(ROW_SIZE, 'month')); if (e.key === 'ArrowLeft') setMonthCursor(c => c.subtract(1, 'month')); if (e.key === 'ArrowRight') setMonthCursor(c => c.add(1, 'month')); setTimeout(focusCursor); }; div.addEventListener('keydown', onKeyDown); return () => { div.removeEventListener('keydown', onKeyDown); }; }, [closeMonthPicker, focusCursor]); const HeaderLeft = useMemo(() => { return ( <button data-testid="month-picker-current-year" onClick={closeMonthPicker} className={styles.calendarHeaderTriggerButton} > {monthCursor.format('YYYY')} </button> ); }, [closeMonthPicker, monthCursor]); const HeaderRight = useMemo(() => { return ( <NavButtons onNext={nextYear} onPrev={prevYear} prevDisabled={prevYearDisabled} nextDisabled={nextYearDisabled} /> ); }, [nextYear, nextYearDisabled, prevYear, prevYearDisabled]); const Body = useMemo(() => { return ( <div className={styles.yearViewBody}> {matrix.map((row, i) => { return ( <div key={i} className={styles.yearViewRow}> {row.map((month, j) => { return ( <div key={j} className={styles.yearViewBodyCell}> <button data-value={month.format('YYYY-MM')} data-is-month-cell className={styles.yearViewBodyCellInner} data-selected={value && month.isSame(value, 'month')} data-current-month={month.isSame(dayjs(), 'month')} onClick={() => onMonthChange(month)} tabIndex={month.isSame(monthCursor, 'month') ? 0 : -1} aria-label={month.format('YYYY-MM')} > {monthNames.split(',')[month.month()]} </button> </div> ); })} </div> ); })} </div> ); }, [matrix, monthCursor, monthNames, onMonthChange, value]); return ( <CalendarLayout mode="month" ref={dayPickerRootRef} length={3} headerLeft={HeaderLeft} headerRight={HeaderRight} body={Body} /> ); });