diff --git a/src/fullcalendar/rendering/eventDidMount.js b/src/fullcalendar/rendering/eventDidMount.js index 8e68a7567b..f74c8687d0 100644 --- a/src/fullcalendar/rendering/eventDidMount.js +++ b/src/fullcalendar/rendering/eventDidMount.js @@ -2,8 +2,125 @@ * SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors * SPDX-License-Identifier: AGPL-3.0-or-later */ +import { getCanonicalLocale, translate as t } from '@nextcloud/l10n' +import { formatDateWithTimezone, isMultiDayAllDayEvent } from '../../utils/date.js' import { errorCatch } from '../utils/errors.js' +/** + * Build time description for all-day events + * + * @param {EventApi} event The event + * @param {string|undefined} locale Locale for event time formatting + * @return {string} Time description + */ +function buildAllDayTimeDescription(event, locale) { + if (!event.start) { + return '' + } + + const dateOptions = { + weekday: 'long', + year: 'numeric', + month: 'long', + day: 'numeric', + } + const startStr = formatDateWithTimezone(event.start, locale, dateOptions) + + if (!event.end || !isMultiDayAllDayEvent(event.start, event.end)) { + return startStr + } + + // Multi-day event: calculate end date (exclusive, so subtract 1 day) + const adjustedEnd = new Date(event.end) + adjustedEnd.setDate(adjustedEnd.getDate() - 1) + const endStr = formatDateWithTimezone(adjustedEnd, locale, dateOptions) + + return t('calendar', '{startDate} to {endDate}', { + startDate: startStr, + endDate: endStr, + }) +} + +/** + * Build time description for timed events + * + * @param {EventApi} event The event + * @param {string|undefined} locale The locale to use + * @return {string} Time description + */ +function buildTimedEventDescription(event, locale) { + if (!event.start) { + return '' + } + + const dateTimeOptions = { + weekday: 'long', + year: 'numeric', + month: 'long', + day: 'numeric', + hour: 'numeric', + minute: 'numeric', + } + const timeOptions = { + hour: 'numeric', + minute: 'numeric', + } + + const startStr = formatDateWithTimezone(event.start, locale, dateTimeOptions, true) + + if (!event.end) { + return startStr + } + + // Check if same day - only show time for end + const sameDay = event.start.toDateString() === event.end.toDateString() + if (sameDay) { + const endTimeStr = formatDateWithTimezone(event.end, locale, timeOptions, true) + return t('calendar', '{startDateTime} to {endTime}', { + startDateTime: startStr, + endTime: endTimeStr, + }) + } + + // Multi-day timed event + const endStr = formatDateWithTimezone(event.end, locale, dateTimeOptions, true) + return t('calendar', '{startDateTime} to {endDateTime}', { + startDateTime: startStr, + endDateTime: endStr, + }) +} + +/** + * Builds an accessible label for a calendar event including its title and time. + * + * @param {EventApi} event The fullcalendar event object + * @return {string} A human-readable label for screen readers + */ +function buildAriaLabel(event) { + const locale = getCanonicalLocale() || undefined + const title = event.title || t('calendar', 'Untitled event') + + if (event.allDay) { + const timeDescription = buildAllDayTimeDescription(event, locale) + if (timeDescription) { + return t('calendar', '{title}, All day: {timeDescription}', { + title, + timeDescription, + }) + } + return t('calendar', '{title}, All day', { title }) + } + + const timeDescription = buildTimedEventDescription(event, locale) + if (timeDescription) { + return t('calendar', '{title}, {timeDescription}', { + title, + timeDescription, + }) + } + return title +} + /** * Adds data to the html element representing the event in the fullcalendar grid. * This is used to later on position the popover @@ -13,9 +130,12 @@ import { errorCatch } from '../utils/errors.js' * @param {Node} data.el The HTML element */ export default errorCatch(function({ event, el }) { + // Set aria-label for screen reader accessibility + el.setAttribute('aria-label', buildAriaLabel(event)) if (el.classList.contains('fc-event-nc-alarms')) { const notificationIcon = document.createElement('span') notificationIcon.classList.add('icon-event-reminder') + notificationIcon.setAttribute('aria-hidden', 'true') if (event.extendedProps.darkText) { notificationIcon.classList.add('icon-event-reminder--dark') } else { @@ -137,7 +257,7 @@ export default errorCatch(function({ event, el }) { const titleElement = el.querySelector('.fc-event-title') if (titleElement) { - const svgString = '' + const svgString = '' titleElement.innerHTML = svgString + titleElement.innerHTML const svgElement = titleElement.querySelector('svg') diff --git a/src/utils/date.js b/src/utils/date.js index 922873b7ae..baa06c48ab 100644 --- a/src/utils/date.js +++ b/src/utils/date.js @@ -3,6 +3,9 @@ * SPDX-License-Identifier: AGPL-3.0-or-later */ +import { DateTimeValue } from '@nextcloud/calendar-js' +import getTimezoneManager from '../services/timezoneDataProviderService.js' +import useSettingsStore from '../store/settings.js' import logger from './logger.js' /** @@ -115,3 +118,55 @@ export function modifyDate(date, { day = 0, week = 0, month = 0, year = 0 }) { return date } + +/** + * Convert a date from UTC to user's timezone + * + * @param {Date} date The date to convert (in UTC) + * @return {Date} Converted date in user's timezone + */ +export function convertDateToUserTimezone(date) { + const settingsStore = useSettingsStore() + const userTimezoneId = settingsStore.getResolvedTimezone + + const tzManager = getTimezoneManager() + const utcTimezone = tzManager.getTimezoneForId('UTC') + const userTimezone = tzManager.getTimezoneForId(userTimezoneId) + + const dateTimeValue = DateTimeValue.fromJSDate(date, true) + dateTimeValue.replaceTimezone(utcTimezone) + return getDateFromDateTimeValue(dateTimeValue.getInTimezone(userTimezone)) +} + +/** + * Format a date with specified options + * + * @param {Date} date The date to format (in UTC for timed events, local for all-day) + * @param {string|undefined} locale The locale to use + * @param {object} options Formatting options + * @param {boolean} convertTimezone Whether to convert from UTC to user timezone + * @return {string} Formatted date string + */ +export function formatDateWithTimezone(date, locale, options, convertTimezone = false) { + const dateToFormat = convertTimezone ? convertDateToUserTimezone(date) : date + return dateToFormat.toLocaleString(locale ?? 'en_001', options) +} + +/** + * Check if an all-day event spans multiple days + * + * @param {Date} start Start date + * @param {Date} end End date (exclusive in FullCalendar) + * @return {boolean} True if multi-day + */ +export function isMultiDayAllDayEvent(start, end) { + // FullCalendar all-day end dates are exclusive, so subtract one day + const adjustedEnd = new Date(end) + adjustedEnd.setDate(adjustedEnd.getDate() - 1) + adjustedEnd.setHours(0, 0, 0, 0) + + const startMidnight = new Date(start) + startMidnight.setHours(0, 0, 0, 0) + + return adjustedEnd.getTime() > startMidnight.getTime() +} diff --git a/tests/javascript/unit/fullcalendar/rendering/eventDidMount.test.js b/tests/javascript/unit/fullcalendar/rendering/eventDidMount.test.js index 9f631777ef..7da52c7761 100644 --- a/tests/javascript/unit/fullcalendar/rendering/eventDidMount.test.js +++ b/tests/javascript/unit/fullcalendar/rendering/eventDidMount.test.js @@ -2,8 +2,13 @@ * SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors * SPDX-License-Identifier: AGPL-3.0-or-later */ +import { translate, getCanonicalLocale } from '@nextcloud/l10n' +import { formatDateWithTimezone, isMultiDayAllDayEvent } from '../../../../../src/utils/date.js' import eventRender from "../../../../../src/fullcalendar/rendering/eventDidMount.js"; +vi.mock('@nextcloud/l10n') +vi.mock('../../../../../src/utils/date.js') + describe('fullcalendar/eventDidMount test suite', () => { it('should add extended properties from the event to the dataset of the dom element - existing event', () => { @@ -66,7 +71,7 @@ describe('fullcalendar/eventDidMount test suite', () => { eventRender({ event, el }) - expect(el.outerHTML).toEqual('
2pmTitle 123
') + expect(el.outerHTML).toEqual('
2pmTitle 123
') }) it('should add an alarm bell icon if event has an alarm - light', () => { @@ -98,7 +103,7 @@ describe('fullcalendar/eventDidMount test suite', () => { eventRender({ event, el }) - expect(el.outerHTML).toEqual('
2pmTitle 123
') + expect(el.outerHTML).toEqual('
2pmTitle 123
') }) // TODO: fix me later @@ -167,3 +172,287 @@ describe('fullcalendar/eventDidMount test suite', () => { // }) }) + +describe('fullcalendar/eventDidMount aria-label test suite', () => { + + beforeEach(() => { + translate.mockClear() + getCanonicalLocale.mockClear() + formatDateWithTimezone.mockClear() + isMultiDayAllDayEvent.mockClear() + + getCanonicalLocale.mockReturnValue('en') + + // Mock translate to perform placeholder substitution like the real function + translate.mockImplementation((app, str, params) => { + if (!params) return str + return Object.entries(params).reduce( + (result, [key, value]) => result.replace(`{${key}}`, value), + str, + ) + }) + }) + + /** + * Helper to create a minimal event object + * + * @param {object} overrides Properties to override on the event + * @return {object} A minimal fullcalendar-like event object + */ + function createEvent(overrides = {}) { + return { + title: 'Team Meeting', + allDay: false, + start: new Date('2026-03-11T10:00:00'), + end: new Date('2026-03-11T11:00:00'), + source: {}, + extendedProps: { + objectId: 'obj1', + recurrenceId: 'rec1', + }, + ...overrides, + } + } + + /** + * Helper to create a DOM element and run eventRender, returning the aria-label + * + * @param {object} event The event object + * @return {string} The aria-label attribute value + */ + function getAriaLabel(event) { + const el = document.createElement('div') + eventRender({ event, el }) + return el.getAttribute('aria-label') + } + + describe('all-day events', () => { + + it('should build an aria-label for a single-day all-day event', () => { + const start = new Date('2026-03-11T00:00:00') + const end = new Date('2026-03-12T00:00:00') + + formatDateWithTimezone.mockReturnValue('Wednesday, March 11, 2026') + isMultiDayAllDayEvent.mockReturnValue(false) + + const label = getAriaLabel(createEvent({ allDay: true, start, end })) + + expect(label).toBe('Team Meeting, All day: Wednesday, March 11, 2026') + expect(formatDateWithTimezone).toHaveBeenCalledWith(start, 'en', expect.objectContaining({ + weekday: 'long', + year: 'numeric', + month: 'long', + day: 'numeric', + })) + }) + + it('should build an aria-label for a multi-day all-day event', () => { + const start = new Date('2026-03-11T00:00:00') + const end = new Date('2026-03-14T00:00:00') + + formatDateWithTimezone + .mockReturnValueOnce('Wednesday, March 11, 2026') + .mockReturnValueOnce('Friday, March 13, 2026') + isMultiDayAllDayEvent.mockReturnValue(true) + + const label = getAriaLabel(createEvent({ allDay: true, start, end })) + + expect(label).toBe('Team Meeting, All day: Wednesday, March 11, 2026 to Friday, March 13, 2026') + // Should have been called twice: once for start, once for adjusted end + expect(formatDateWithTimezone).toHaveBeenCalledTimes(2) + }) + + it('should handle an all-day event with no end date', () => { + const start = new Date('2026-03-11T00:00:00') + + formatDateWithTimezone.mockReturnValue('Wednesday, March 11, 2026') + + const label = getAriaLabel(createEvent({ allDay: true, start, end: null })) + + expect(label).toBe('Team Meeting, All day: Wednesday, March 11, 2026') + }) + + it('should handle an all-day event with no start date', () => { + const label = getAriaLabel(createEvent({ allDay: true, start: null, end: null })) + + expect(label).toBe('Team Meeting, All day') + expect(formatDateWithTimezone).not.toHaveBeenCalled() + }) + }) + + describe('timed events', () => { + + it('should build an aria-label for a same-day timed event', () => { + const start = new Date('2026-03-11T10:00:00Z') + const end = new Date('2026-03-11T11:00:00Z') + // Make toDateString() match for same-day detection + start.toDateString = () => 'Wed Mar 11 2026' + end.toDateString = () => 'Wed Mar 11 2026' + + formatDateWithTimezone + .mockReturnValueOnce('Wednesday, March 11, 2026, 10:00 AM') + .mockReturnValueOnce('11:00 AM') + + const label = getAriaLabel(createEvent({ allDay: false, start, end })) + + expect(label).toBe('Team Meeting, Wednesday, March 11, 2026, 10:00 AM to 11:00 AM') + expect(formatDateWithTimezone).toHaveBeenCalledTimes(2) + // First call: full datetime for start + expect(formatDateWithTimezone).toHaveBeenCalledWith(start, 'en', expect.objectContaining({ + weekday: 'long', + hour: 'numeric', + minute: 'numeric', + }), true) + // Second call: time-only for end + expect(formatDateWithTimezone).toHaveBeenCalledWith(end, 'en', expect.objectContaining({ + hour: 'numeric', + minute: 'numeric', + }), true) + }) + + it('should build an aria-label for a multi-day timed event', () => { + const start = new Date('2026-03-11T10:00:00Z') + const end = new Date('2026-03-12T14:00:00Z') + start.toDateString = () => 'Wed Mar 11 2026' + end.toDateString = () => 'Thu Mar 12 2026' + + formatDateWithTimezone + .mockReturnValueOnce('Wednesday, March 11, 2026, 10:00 AM') + .mockReturnValueOnce('Thursday, March 12, 2026, 2:00 PM') + + const label = getAriaLabel(createEvent({ allDay: false, start, end })) + + expect(label).toBe('Team Meeting, Wednesday, March 11, 2026, 10:00 AM to Thursday, March 12, 2026, 2:00 PM') + expect(formatDateWithTimezone).toHaveBeenCalledTimes(2) + // Both calls use full dateTime options + expect(formatDateWithTimezone).toHaveBeenNthCalledWith(1, start, 'en', expect.objectContaining({ + weekday: 'long', + year: 'numeric', + month: 'long', + day: 'numeric', + hour: 'numeric', + minute: 'numeric', + }), true) + expect(formatDateWithTimezone).toHaveBeenNthCalledWith(2, end, 'en', expect.objectContaining({ + weekday: 'long', + year: 'numeric', + month: 'long', + day: 'numeric', + hour: 'numeric', + minute: 'numeric', + }), true) + }) + + it('should handle a timed event with no end date', () => { + const start = new Date('2026-03-11T10:00:00Z') + + formatDateWithTimezone.mockReturnValue('Wednesday, March 11, 2026, 10:00 AM') + + const label = getAriaLabel(createEvent({ allDay: false, start, end: null })) + + expect(label).toBe('Team Meeting, Wednesday, March 11, 2026, 10:00 AM') + expect(formatDateWithTimezone).toHaveBeenCalledTimes(1) + }) + + it('should handle a timed event with no start date', () => { + const label = getAriaLabel(createEvent({ allDay: false, start: null, end: null })) + + expect(label).toBe('Team Meeting') + expect(formatDateWithTimezone).not.toHaveBeenCalled() + }) + }) + + describe('edge cases', () => { + + it('should use "Untitled event" for events with no title', () => { + const start = new Date('2026-03-11T00:00:00') + + formatDateWithTimezone.mockReturnValue('Wednesday, March 11, 2026') + isMultiDayAllDayEvent.mockReturnValue(false) + + const label = getAriaLabel(createEvent({ title: '', allDay: true, start, end: null })) + + expect(label).toBe('Untitled event, All day: Wednesday, March 11, 2026') + }) + + it('should use "Untitled event" for events with undefined title', () => { + const label = getAriaLabel(createEvent({ title: undefined, allDay: false, start: null, end: null })) + + expect(label).toBe('Untitled event') + }) + + it('should set the aria-label attribute on the DOM element', () => { + const start = new Date('2026-03-11T00:00:00') + + formatDateWithTimezone.mockReturnValue('Wednesday, March 11, 2026') + isMultiDayAllDayEvent.mockReturnValue(false) + + const el = document.createElement('div') + const event = createEvent({ allDay: true, start, end: null }) + + eventRender({ event, el }) + + expect(el.hasAttribute('aria-label')).toBe(true) + expect(el.getAttribute('aria-label')).toBeTruthy() + expect(el.getAttribute('aria-label')).toContain('Team Meeting') + }) + + it('should use all localized strings via t() and not hardcode separators', () => { + const start = new Date('2026-03-11T10:00:00Z') + const end = new Date('2026-03-11T11:00:00Z') + start.toDateString = () => 'Wed Mar 11 2026' + end.toDateString = () => 'Wed Mar 11 2026' + + formatDateWithTimezone + .mockReturnValueOnce('formatted-start') + .mockReturnValueOnce('formatted-end') + + getAriaLabel(createEvent({ allDay: false, start, end })) + + // Verify that translate was called for combining title + time description + // (not just raw string concatenation) + const translateCalls = translate.mock.calls + const ariaLabelCall = translateCalls.find( + call => call[1] === '{title}, {timeDescription}', + ) + expect(ariaLabelCall).toBeTruthy() + expect(ariaLabelCall[2]).toEqual({ + title: 'Team Meeting', + timeDescription: 'formatted-start to formatted-end', + }) + }) + + it('should pass through getCanonicalLocale to formatDateWithTimezone', () => { + getCanonicalLocale.mockReturnValue('ar') + + const start = new Date('2026-03-11T00:00:00') + formatDateWithTimezone.mockReturnValue('الأربعاء، ١١ مارس ٢٠٢٦') + isMultiDayAllDayEvent.mockReturnValue(false) + + getAriaLabel(createEvent({ allDay: true, start, end: null })) + + expect(formatDateWithTimezone).toHaveBeenCalledWith( + start, + 'ar', + expect.any(Object), + ) + }) + + it('should handle undefined locale from getCanonicalLocale', () => { + getCanonicalLocale.mockReturnValue('') + + const start = new Date('2026-03-11T00:00:00') + formatDateWithTimezone.mockReturnValue('Wednesday, March 11, 2026') + isMultiDayAllDayEvent.mockReturnValue(false) + + getAriaLabel(createEvent({ allDay: true, start, end: null })) + + // Empty string is falsy, so locale should be undefined + expect(formatDateWithTimezone).toHaveBeenCalledWith( + start, + undefined, + expect.any(Object), + ) + }) + }) +})