From 294023e2b29b5ce2d767e8b438dc16384095be3c Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Sat, 31 Oct 2026 04:18:17 +0100 Subject: [PATCH] fix: read month days with their date and event count and events with title, date and time to screen readers --- src/components/calendar/EventBlock.tsx | 10 +++ src/components/calendar/MonthView.tsx | 8 ++- .../calendar/TimeGridScrollView.tsx | 5 +- src/components/calendar/WeekView.tsx | 3 + src/lib/__tests__/calendar-a11y.test.ts | 48 +++++++++++++++ src/lib/calendar-a11y.ts | 61 +++++++++++++++++++ 6 files changed, 133 insertions(+), 2 deletions(-) create mode 100644 src/lib/__tests__/calendar-a11y.test.ts create mode 100644 src/lib/calendar-a11y.ts diff --git a/src/components/calendar/EventBlock.tsx b/src/components/calendar/EventBlock.tsx index 46e3df8c..57cce7c5 100644 --- a/src/components/calendar/EventBlock.tsx +++ b/src/components/calendar/EventBlock.tsx @@ -54,6 +54,8 @@ interface TimedEventBlockProps { /** Position and size in the day column. */ style: StyleProp; onPress?: () => void; + /** What a screen reader says: title, date, time (see lib/calendar-a11y). */ + accessibilityLabel?: string; } /** A timed event in a day column. */ @@ -66,11 +68,14 @@ export function TimedEventBlock({ continuesAfter, style, onPress, + accessibilityLabel, }: TimedEventBlockProps) { const inactive = colors.border !== null; return ( ; onPress?: () => void; + /** What a screen reader says: title, date, time (see lib/calendar-a11y). */ + accessibilityLabel?: string; } /** An all-day or multi-day event in the strip above the grid. */ @@ -124,10 +131,13 @@ export function AllDayEventBar({ continuesAfter, style, onPress, + accessibilityLabel, }: AllDayEventBarProps) { const inactive = colors.border !== null; return ( onSelectDate(d)} onLongPress={onLongPressDate ? () => onLongPressDate(d) : undefined} + accessibilityRole="button" + accessibilityLabel={dayCellAccessibilityLabel(d, dayEvents.length, t, locale)} + accessibilityState={{ selected }} > @@ -341,7 +347,7 @@ function FullWeekRow({ onPress={() => onSelectDate(d)} onLongPress={onLongPressDate ? () => onLongPressDate(d) : undefined} accessibilityRole="button" - accessibilityLabel={format(d, 'PPPP', { locale })} + accessibilityLabel={dayCellAccessibilityLabel(d, dayEvents.length, t, locale)} > diff --git a/src/components/calendar/TimeGridScrollView.tsx b/src/components/calendar/TimeGridScrollView.tsx index 8c226637..8ded28a2 100644 --- a/src/components/calendar/TimeGridScrollView.tsx +++ b/src/components/calendar/TimeGridScrollView.tsx @@ -20,6 +20,7 @@ import type { Calendar, CalendarEvent } from '../../api/types'; import { spacing, typography, type ThemePalette } from '../../theme/tokens'; import { useColors } from '../../theme/colors'; import { useCalendarLocale } from '../../lib/calendar-locale'; +import { eventAccessibilityLabel } from '../../lib/calendar-a11y'; import { buildEventDayIndex, dayKey, @@ -439,6 +440,7 @@ function TimeGridScrollViewInner({ )} continuesBefore={segment.continuesBefore} continuesAfter={segment.continuesAfter} + accessibilityLabel={eventAccessibilityLabel(segment.event, { t, locale, timeFormat, calendars, currentUserEmails })} onPress={() => onSelectEvent?.(segment.event)} style={{ left: segment.startIndex * colWidth + 1, @@ -592,7 +594,7 @@ const DayColumn = React.memo(function DayColumn({ /** Hour of the "New event" block in this column, or -1. */ draftHour: number; }) { - const { t } = useCalendarLocale(); + const { locale, t } = useCalendarLocale(); const handleLongPress = React.useCallback( (e: GestureResponderEvent) => { onLongPressAt?.(day, hourAtOffset(e.nativeEvent.locationY, HOUR_HEIGHT)); @@ -630,6 +632,7 @@ const DayColumn = React.memo(function DayColumn({ ringColor={colors.background} continuesBefore={continuesBefore} continuesAfter={continuesAfter} + accessibilityLabel={eventAccessibilityLabel(event, { t, locale, timeFormat, calendars, currentUserEmails })} onPress={() => onSelectEvent?.(event)} style={{ top, height, left: `${column * widthPct}%`, width: `${widthPct}%` }} /> diff --git a/src/components/calendar/WeekView.tsx b/src/components/calendar/WeekView.tsx index d3c156d9..0d7718c9 100644 --- a/src/components/calendar/WeekView.tsx +++ b/src/components/calendar/WeekView.tsx @@ -12,6 +12,7 @@ import type { Calendar, CalendarEvent } from '../../api/types'; import { spacing, typography, type ThemePalette } from '../../theme/tokens'; import { useColors } from '../../theme/colors'; import { useCalendarLocale } from '../../lib/calendar-locale'; +import { eventAccessibilityLabel } from '../../lib/calendar-a11y'; import { buildEventDayIndex, buildTimedFullDayWeekSegments, @@ -202,6 +203,7 @@ function WeekViewInner({ )} continuesBefore={segment.continuesBefore} continuesAfter={segment.continuesAfter} + accessibilityLabel={eventAccessibilityLabel(segment.event, { t, locale, timeFormat, calendars, currentUserEmails })} onPress={() => onSelectEvent?.(segment.event)} style={{ left: `${leftPct}%`, @@ -277,6 +279,7 @@ function WeekViewInner({ ringColor={c.background} continuesBefore={continuesBefore} continuesAfter={continuesAfter} + accessibilityLabel={eventAccessibilityLabel(event, { t, locale, timeFormat, calendars, currentUserEmails })} onPress={() => onSelectEvent?.(event)} style={{ top, height, left: `${leftPct}%`, width: `${widthPct}%` }} /> diff --git a/src/lib/__tests__/calendar-a11y.test.ts b/src/lib/__tests__/calendar-a11y.test.ts new file mode 100644 index 00000000..413a9156 --- /dev/null +++ b/src/lib/__tests__/calendar-a11y.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, it, vi } from 'vitest'; + +vi.hoisted(() => { + process.env.TZ = 'Europe/Berlin'; +}); + +import { enUS } from 'date-fns/locale'; +import type { CalendarEvent } from '../../api/types'; +import { formatMessage } from '../../i18n/format'; +import { dayCellAccessibilityLabel, eventAccessibilityLabel } from '../calendar-a11y'; +import { useSettingsStore } from '../../stores/settings-store'; + +const t = (_key: string, fallback?: string, params?: Record) => + formatMessage(fallback ?? '', params, 'en'); + +useSettingsStore.setState({ calendarTimeZone: 'auto' } as never); + +const event = (extra: Partial): CalendarEvent => ({ + id: 'e1', uid: 'u1', title: 'Standup', calendarIds: { work: true }, + start: '2026-11-02T09:00:00', duration: 'PT15M', timeZone: 'Europe/Berlin', + ...extra, +} as CalendarEvent); + +describe('dayCellAccessibilityLabel', () => { + it('reads a month day cell as its full date and how many events it has', () => { + const day = new Date(2026, 10, 2); + expect(dayCellAccessibilityLabel(day, 3, t, enUS)).toMatch(/^Monday, Nov(ember)? 2, 2026, 3 events$/); + expect(dayCellAccessibilityLabel(day, 1, t, enUS)).toMatch(/, 1 event$/); + expect(dayCellAccessibilityLabel(day, 0, t, enUS)).toMatch(/^Monday, Nov(ember)? 2, 2026$/); + }); +}); + +describe('eventAccessibilityLabel', () => { + const calendars = [{ id: 'work', name: 'Work' }]; + + it('reads an event block as title, date, time and calendar', () => { + expect(eventAccessibilityLabel(event({}), { t, locale: enUS, timeFormat: '24h', calendars })) + .toMatch(/^Standup, Monday, Nov(ember)? 2, 2026, 09:00 – 09:15, Work$/); + }); + + it('says all day for an all-day event and names a cancelled one', () => { + const allDay = event({ start: '2026-11-02T00:00:00', duration: 'P1D', showWithoutTime: true, timeZone: null as never }); + expect(eventAccessibilityLabel(allDay, { t, locale: enUS, timeFormat: '24h' })) + .toMatch(/^Standup, Monday, Nov(ember)? 2, 2026, All day$/); + expect(eventAccessibilityLabel(event({ status: 'cancelled', title: '' }), { t, locale: enUS, timeFormat: '12h' })) + .toMatch(/^\(No title\), Monday, Nov(ember)? 2, 2026, 9:00 AM – 9:15 AM, Cancelled$/); + }); +}); diff --git a/src/lib/calendar-a11y.ts b/src/lib/calendar-a11y.ts new file mode 100644 index 00000000..0a55b64a --- /dev/null +++ b/src/lib/calendar-a11y.ts @@ -0,0 +1,61 @@ +import { format, isSameDay } from 'date-fns'; +import type { Locale } from 'date-fns'; +import type { Calendar, CalendarEvent } from '../api/types'; +import { getEventDisplayEndDate, getEventStartDate, getPrimaryCalendarId, timePattern } from './calendar-utils'; +import { isDeclinedByUser } from './calendar-participants'; +import { formatCalendarDate } from './date-format'; + +type Translate = (key: string, fallback?: string, params?: Record) => string; + +// What a screen reader says for the calendar grid (webmail 0026abba4, +// 19e0acde9): a day cell by its full date and how many events it has, an +// event by its title, date, time, calendar and state. + +/** "Monday, November 2, 2026, 3 events" for a month day cell. */ +export function dayCellAccessibilityLabel(day: Date, eventCount: number, t: Translate, locale: Locale): string { + const date = formatCalendarDate(day, locale.code, 'longWeekdayMonthDayYear'); + if (eventCount === 0) return date; + return `${date}, ${t('calendar.a11y.event_count', '{count, plural, one {# event} other {# events}}', { count: eventCount })}`; +} + +/** "Standup, Monday, November 2, 2026, 09:00 – 09:15, Work, Cancelled" for an event block. */ +export function eventAccessibilityLabel( + event: CalendarEvent, + opts: { + t: Translate; + locale: Locale; + timeFormat: '12h' | '24h' | undefined; + calendars?: Pick[]; + currentUserEmails?: string[]; + }, +): string { + const { t, locale } = opts; + const parts = [event.title || t('calendar.events.no_title', '(No title)')]; + const start = getEventStartDate(event); + const end = getEventDisplayEndDate(event); + const valid = (d: Date) => !isNaN(d.getTime()); + if (valid(start)) { + const date = formatCalendarDate(start, locale.code, 'longWeekdayMonthDayYear'); + if (event.showWithoutTime) { + const last = valid(end) && !isSameDay(start, end) + ? ` – ${formatCalendarDate(end, locale.code, 'longWeekdayMonthDayYear')}` + : ''; + parts.push(date + last, t('calendar.events.all_day', 'All day')); + } else { + const time = timePattern(opts.timeFormat); + let range = format(start, time, { locale }); + if (valid(end) && end.getTime() > start.getTime()) { + range += isSameDay(start, end) + ? ` – ${format(end, time, { locale })}` + : ` – ${formatCalendarDate(end, locale.code, 'longWeekdayMonthDayYear')} ${format(end, time, { locale })}`; + } + parts.push(date, range); + } + } + const calendarId = getPrimaryCalendarId(event); + const calendar = calendarId ? opts.calendars?.find((c) => c.id === calendarId) : undefined; + if (calendar?.name) parts.push(calendar.name); + if (event.status === 'cancelled') parts.push(t('calendar.detail.cancelled', 'Cancelled')); + else if (isDeclinedByUser(event, opts.currentUserEmails)) parts.push(t('calendar.participants.declined', 'Declined')); + return parts.join(', '); +}