From 19e0acde9185a96e207169d48165a5bca21765cf Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Thu, 29 Oct 2026 15:53:22 +0100 Subject: [PATCH] fix: make the mini calendar a grid with arrow keys, full date names and event days in words --- .../calendar/__tests__/mini-calendar.test.tsx | 94 +++++++ components/calendar/mini-calendar.tsx | 236 +++++++++++++----- locales/ar/common.json | 4 +- locales/ca/common.json | 4 +- locales/cs/common.json | 4 +- locales/da/common.json | 4 +- locales/de/common.json | 4 +- locales/en/common.json | 4 +- locales/es/common.json | 4 +- locales/et/common.json | 4 +- locales/fa/common.json | 4 +- locales/fr/common.json | 4 +- locales/he/common.json | 4 +- locales/hu/common.json | 4 +- locales/it/common.json | 4 +- locales/ja/common.json | 4 +- locales/ko/common.json | 4 +- locales/lv/common.json | 4 +- locales/mn/common.json | 4 +- locales/nb/common.json | 4 +- locales/nl/common.json | 4 +- locales/pl/common.json | 4 +- locales/pt/common.json | 4 +- locales/ro/common.json | 4 +- locales/ru/common.json | 4 +- locales/sk/common.json | 4 +- locales/th/common.json | 4 +- locales/tr/common.json | 4 +- locales/uk/common.json | 4 +- locales/zh-TW/common.json | 4 +- locales/zh/common.json | 4 +- 31 files changed, 349 insertions(+), 97 deletions(-) create mode 100644 components/calendar/__tests__/mini-calendar.test.tsx diff --git a/components/calendar/__tests__/mini-calendar.test.tsx b/components/calendar/__tests__/mini-calendar.test.tsx new file mode 100644 index 000000000..7fdf46f69 --- /dev/null +++ b/components/calendar/__tests__/mini-calendar.test.tsx @@ -0,0 +1,94 @@ +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { render, screen, fireEvent, cleanup, within } from '@testing-library/react'; +import { useState } from 'react'; +import { MiniCalendar } from '../mini-calendar'; +import type { CalendarEvent } from '@/lib/jmap/types'; + +// SR-14: the mini calendar is a real grid, its days are named with the full +// date, event days are marked in words and the arrows move between days. + +const event = { + id: 'e1', + '@type': 'Event', + title: 'Dentist', + start: '2026-10-29T10:00:00', + duration: 'PT1H', + showWithoutTime: false, + calendarIds: { c1: true }, +} as unknown as CalendarEvent; + +function Harness({ onSelectDate = vi.fn(), onChangeMonth }: { onSelectDate?: (d: Date) => void; onChangeMonth?: (d: Date) => void }) { + const [month, setMonth] = useState(new Date(2026, 9, 1)); + return ( + { onChangeMonth?.(d); setMonth(new Date(d.getFullYear(), d.getMonth(), 1)); }} + events={[event]} + showWeekNumbers + /> + ); +} + +const day = (name: RegExp) => screen.getByRole('button', { name }); + +describe('MiniCalendar', () => { + afterEach(cleanup); + + it('is a grid of rows and cells with weekday column headers', () => { + render(); + const grid = screen.getByRole('grid'); + expect(within(grid).getAllByRole('columnheader').length).toBe(8); + expect(within(grid).getAllByRole('row').length).toBeGreaterThanOrEqual(5); + const cell = day(/October 15, 2026/).closest('[role="gridcell"]'); + expect(cell).toHaveAttribute('aria-selected', 'true'); + expect(within(grid).getAllByRole('rowheader', { name: /mini_week_number/ }).length).toBeGreaterThanOrEqual(5); + }); + + it('names each day with its full date and says which days have events', () => { + render(); + expect(day(/^Thursday, October 29, 2026, mini_has_events$/)).toBeInTheDocument(); + expect(day(/^Wednesday, October 28, 2026$/)).toBeInTheDocument(); + }); + + it('is one tab stop, on the selected day', () => { + render(); + const stops = screen.getByRole('grid').querySelectorAll('button[tabindex="0"]'); + expect(stops).toHaveLength(1); + expect(stops[0]).toBe(day(/October 15, 2026/)); + }); + + it('moves focus with the arrow keys without selecting', () => { + const onSelectDate = vi.fn(); + render(); + day(/October 15, 2026/).focus(); + fireEvent.keyDown(document.activeElement!, { key: 'ArrowRight' }); + expect(document.activeElement).toBe(day(/October 16, 2026/)); + fireEvent.keyDown(document.activeElement!, { key: 'ArrowDown' }); + expect(document.activeElement).toBe(day(/October 23, 2026/)); + expect(document.activeElement).toHaveAttribute('tabindex', '0'); + expect(onSelectDate).not.toHaveBeenCalled(); + fireEvent.click(document.activeElement!); + expect(onSelectDate).toHaveBeenCalledWith(new Date(2026, 9, 23)); + }); + + it('turns the month when the arrows leave it and keeps focus on the new day', () => { + const onChangeMonth = vi.fn(); + render(); + day(/October 29, 2026/).focus(); + fireEvent.keyDown(document.activeElement!, { key: 'ArrowDown' }); + expect(onChangeMonth).toHaveBeenCalled(); + expect(screen.getByRole('grid')).toHaveAccessibleName(/November/); + expect(document.activeElement).toBe(day(/November 5, 2026/)); + fireEvent.keyDown(document.activeElement!, { key: 'PageUp' }); + expect(document.activeElement).toBe(day(/October 5, 2026/)); + }); + + it('tells which month is selected in the month picker', () => { + render(); + fireEvent.click(screen.getByRole('button', { name: /October 2026/ })); + expect(screen.getByRole('button', { name: 'months.oct' })).toHaveAttribute('aria-pressed', 'true'); + expect(screen.getByRole('button', { name: 'months.sep' })).toHaveAttribute('aria-pressed', 'false'); + }); +}); diff --git a/components/calendar/mini-calendar.tsx b/components/calendar/mini-calendar.tsx index 8604a1ae1..5fe39e4a6 100644 --- a/components/calendar/mini-calendar.tsx +++ b/components/calendar/mini-calendar.tsx @@ -1,10 +1,10 @@ "use client"; -import { useState, useMemo, Fragment } from "react"; +import { useState, useMemo, useRef, useEffect, useCallback } from "react"; import { useTranslations } from "next-intl"; import { ChevronLeft, ChevronRight, ChevronDown } from "@/components/icons"; import { - addMonths, subMonths, addYears, subYears, setMonth, setYear, + addDays, addMonths, subMonths, addYears, subYears, setMonth, setYear, getISOWeek, getWeek, format, } from "date-fns"; import { cn } from "@/lib/utils"; @@ -12,6 +12,7 @@ import { getEventDayBounds } from "@/lib/calendar-utils"; import { displayNow } from "@/lib/timezone"; import type { CalendarEvent } from "@/lib/jmap/types"; import { useCalendarLocale } from "@/hooks/use-calendar-locale"; +import { useDisplayDateFormatter } from "@/hooks/use-display-date-formatter"; import { PluginDayLabel } from "./plugin-day-label"; import { IconButton } from "@/components/ui/icon-button"; @@ -27,6 +28,8 @@ interface MiniCalendarProps { showWeekNumbers?: boolean; } +const dayKey = (date: Date) => format(date, "yyyy-MM-dd"); + export function MiniCalendar({ selectedDate, displayMonth, @@ -46,16 +49,23 @@ export function MiniCalendar({ checkIsSameDay, formatDayNumber, formatMonthYear, + formatFullDate, getMonth, getYear, monthLabelKeys, } = useCalendarLocale(); + const intlFormatter = useDisplayDateFormatter(); const [pickerView, setPickerView] = useState("days"); const days = useMemo( () => getMonthGridDays(displayMonth), [displayMonth, getMonthGridDays], ); + const weeks = useMemo(() => { + const rows: Date[][] = []; + for (let i = 0; i < days.length; i += 7) rows.push(days.slice(i, i + 7)); + return rows; + }, [days]); const eventDates = useMemo(() => { const set = new Set(); @@ -64,7 +74,7 @@ export function MiniCalendar({ const { startDay, endDay } = getEventDayBounds(e); const cursor = new Date(startDay); while (cursor <= endDay) { - set.add(format(cursor, "yyyy-MM-dd")); + set.add(dayKey(cursor)); cursor.setDate(cursor.getDate() + 1); } } catch { /* skip */ } @@ -72,16 +82,67 @@ export function MiniCalendar({ return set; }, [events]); - // Compute week numbers for each row (one per 7-day chunk) - const weekNumbers = useMemo(() => { - if (!showWeekNumbers) return []; - const nums: number[] = []; - for (let i = 0; i < days.length; i += 7) { - // Use the first day of each row to determine the week number - nums.push(weekStartsOn === 1 ? getISOWeek(days[i]) : getWeek(days[i], { weekStartsOn: 0 })); + const weekNumberOf = (day: Date) => + weekStartsOn === 1 ? getISOWeek(day) : getWeek(day, { weekStartsOn: 0 }); + + // The grid is one tab stop (WCAG 2.1.1, ARIA grid pattern): the day the + // arrows last moved to, else the selected day, today or the 1st. + const [focusedKey, setFocusedKey] = useState(null); + const pendingFocus = useRef(null); + const gridRef = useRef(null); + const tabStopKey = useMemo(() => { + const inMonth = (day: Date) => checkIsSameMonth(day, displayMonth); + const focused = focusedKey ? days.find((day) => dayKey(day) === focusedKey) : undefined; + if (focused && inMonth(focused)) return focusedKey!; + if (inMonth(selectedDate)) return dayKey(selectedDate); + const today = days.find((day) => inMonth(day) && checkIsToday(day)); + if (today) return dayKey(today); + const first = days.find(inMonth); + return first ? dayKey(first) : null; + }, [focusedKey, days, displayMonth, selectedDate, checkIsSameMonth, checkIsToday]); + + const dayButton = (key: string) => + gridRef.current?.querySelector(`[data-mini-day="${key}"]`) ?? null; + + // After moving into another month, focus the day once that month is drawn. + useEffect(() => { + if (!pendingFocus.current) return; + const el = dayButton(pendingFocus.current); + pendingFocus.current = null; + el?.focus(); + }, [days]); + + const moveFocus = useCallback((date: Date) => { + const key = dayKey(date); + setFocusedKey(key); + if (checkIsSameMonth(date, displayMonth)) { + dayButton(key)?.focus(); + } else { + pendingFocus.current = key; + onChangeMonth(date); } - return nums; - }, [days, showWeekNumbers, weekStartsOn]); + }, [checkIsSameMonth, displayMonth, onChangeMonth]); + + const handleDayKeyDown = (e: React.KeyboardEvent, day: Date, column: number) => { + if (e.altKey || e.ctrlKey || e.metaKey) return; + const rtl = getComputedStyle(e.currentTarget).direction === "rtl"; + let next: Date | null = null; + switch (e.key) { + case "ArrowLeft": next = addDays(day, rtl ? 1 : -1); break; + case "ArrowRight": next = addDays(day, rtl ? -1 : 1); break; + case "ArrowUp": next = addDays(day, -7); break; + case "ArrowDown": next = addDays(day, 7); break; + case "Home": next = addDays(day, -column); break; + case "End": next = addDays(day, 6 - column); break; + case "PageUp": next = e.shiftKey ? subYears(day, 1) : subMonths(day, 1); break; + case "PageDown": next = e.shiftKey ? addYears(day, 1) : addMonths(day, 1); break; + default: return; + } + // The grid's keys, not the calendar's previous/next period. + e.preventDefault(); + e.stopPropagation(); + moveFocus(next); + }; const currentYear = getYear(displayMonth); const currentMonth = getMonth(displayMonth); @@ -120,7 +181,7 @@ export function MiniCalendar({ ? formatMonthYear(displayMonth) : pickerView === "months" ? String(currentYear) - : `${decadeStart}\u2013${decadeStart + 9}`; + : `${decadeStart}–${decadeStart + 9}`; return (
@@ -136,6 +197,7 @@ export function MiniCalendar({ onClick={handleHeaderClick} disabled={pickerView === "years"} title={pickerView !== "years" ? t("mini_calendar_change") : undefined} + aria-live="polite" className={cn( "text-sm font-medium px-2 py-1 rounded-md transition-colors inline-flex items-center gap-1", pickerView !== "years" && "hover:bg-muted cursor-pointer", @@ -144,7 +206,7 @@ export function MiniCalendar({ > {headerLabel} {pickerView !== "years" && ( - +