mirror of
https://github.com/bulwarkmail/native.git
synced 2026-11-01 00:07:01 +00:00
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:
@@ -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,
|
||||
|
||||
@@ -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}%` }}
|
||||
/>
|
||||
|
||||
@@ -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}%` }}
|
||||
/>
|
||||
|
||||
@@ -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$/);
|
||||
});
|
||||
});
|
||||
@@ -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(', ');
|
||||
}
|
||||
Reference in New Issue
Block a user