fix: read month days with their date and event count and events with title, date and time to screen readers

This commit is contained in:
Linus Rath
2026-10-31 04:18:17 +01:00
parent 6c6991ace8
commit 294023e2b2
6 changed files with 133 additions and 2 deletions
+10
View File
@@ -54,6 +54,8 @@ interface TimedEventBlockProps {
/** Position and size in the day column. */
style: StyleProp<ViewStyle>;
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 (
<Pressable
onPress={onPress}
accessibilityRole="button"
accessibilityLabel={accessibilityLabel ?? title}
style={[
styles.block,
corners('vertical', continuesBefore, continuesAfter, EVENT_RADIUS),
@@ -114,6 +119,8 @@ interface AllDayEventBarProps {
/** Position and size in the all-day strip. */
style: StyleProp<ViewStyle>;
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 (
<Pressable
accessibilityRole="button"
accessibilityLabel={accessibilityLabel ?? title}
onPress={onPress}
style={[
styles.bar,
+7 -1
View File
@@ -28,6 +28,7 @@ import { dayIndexIn, monthChipShowsTime, monthKeyOf, monthMask } from '../../lib
import { isInactiveEvent } from '../../lib/calendar-participants';
import { eventBlockColors } from '../../lib/event-colors';
import { useCalendarLocale } from '../../lib/calendar-locale';
import { dayCellAccessibilityLabel } from '../../lib/calendar-a11y';
import { displayNow } from '../../lib/calendar-timezone';
type WeekStart = 0 | 1 | 6;
@@ -153,6 +154,7 @@ function MonthWeekRowInner({
onLongPressDate,
}: MonthWeekRowProps) {
const c = useColors();
const { t } = useCalendarLocale();
const { width } = useWindowDimensions();
const chipTime = monthChipShowsTime(true, width / 7);
if (variant === 'full') {
@@ -201,6 +203,9 @@ function MonthWeekRowInner({
style={[styles.dayCell, showTimeInMonthView && styles.dayCellTall]}
onPress={() => onSelectDate(d)}
onLongPress={onLongPressDate ? () => onLongPressDate(d) : undefined}
accessibilityRole="button"
accessibilityLabel={dayCellAccessibilityLabel(d, dayEvents.length, t, locale)}
accessibilityState={{ selected }}
>
<View style={[
styles.dayNumber,
@@ -321,6 +326,7 @@ function FullWeekRow({
colors: ThemePalette;
}) {
const { width } = useWindowDimensions();
const { t } = useCalendarLocale();
const showTime = monthChipShowsTime(showTimeInMonthView, width / 7);
return (
<View style={[styles.fullWeekRow, { height }]}>
@@ -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)}
>
<View style={styles.fullNumberWrap}>
<View style={[styles.fullNumber, today && styles.fullNumberToday]}>
@@ -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}%` }}
/>
+3
View File
@@ -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}%` }}
/>
+48
View File
@@ -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<string, string | number>) =>
formatMessage(fallback ?? '', params, 'en');
useSettingsStore.setState({ calendarTimeZone: 'auto' } as never);
const event = (extra: Partial<CalendarEvent>): 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$/);
});
});
+61
View File
@@ -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, string | number>) => 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<Calendar, 'id' | 'name'>[];
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(', ');
}