diff --git a/e2e/testcafe-devextreme/tests/scheduler/common/a11y/appointment.ts b/e2e/testcafe-devextreme/tests/scheduler/common/a11y/appointment.ts index 9848a08fc586..ef3dd86d4616 100644 --- a/e2e/testcafe-devextreme/tests/scheduler/common/a11y/appointment.ts +++ b/e2e/testcafe-devextreme/tests/scheduler/common/a11y/appointment.ts @@ -7,49 +7,54 @@ import { checkOptions } from './axe_options'; fixture.disablePageReloads`a11y - appointment` .page(url(__dirname, '../../../container.html')); +const appointmentItem = { + text: 'App 1', + startDate: Date.UTC(2021, 1, 1, 12), + endDate: Date.UTC(2021, 1, 1, 13), +}; +const currentDate = Date.UTC(2021, 1, 1); + ['month', 'week', 'day'].forEach((currentView) => { - test(`appointment should have correct aria-label without grouping (${currentView})`, async (t) => { + test(`appointment should have correct aria-label without description (${currentView})`, async (t) => { const scheduler = new Scheduler('#container'); + const appointment = scheduler.getAppointment('App 1'); await t - .expect( - scheduler.getAppointment('App 1').element.attributes['aria-label'], - ) - .eql(undefined); + .expect(appointment.getAriaLabel()) + .eql('App 1: February 1, 2021, 12:00 PM - 1:00 PM') + .expect(await appointment.hasAriaDescription()) + .notOk(); await a11yCheck(t, checkOptions, '#container'); }).before(async () => { await createWidget('dxScheduler', { - dataSource: [{ - text: 'App 1', - startDate: new Date(2021, 1, 1, 12), - endDate: new Date(2021, 1, 1, 13), - }], + timeZone: 'UTC', + dataSource: [appointmentItem], currentView, - currentDate: new Date(2021, 1, 1), + currentDate, }); }); test(`appointment should have correct aria-label with one group (${currentView})`, async (t) => { const scheduler = new Scheduler('#container'); - - const attrs = await scheduler.getAppointment('App 1').element.attributes; + const appointment = scheduler.getAppointment('App 1'); await t - .expect(attrs['aria-roledescription']) - .eql('February 1, 2021, Group: resource1, '); + .expect(appointment.getAriaLabel()) + .eql('App 1: February 1, 2021, 12:00 PM - 1:00 PM') + .expect(await appointment.getAriaDescription()) + .eql('Group: resource1; Group 1: resource1'); await a11yCheck(t, checkOptions, '#container'); }).before(async () => { await createWidget('dxScheduler', { + timeZone: 'UTC', dataSource: [{ - text: 'App 1', - startDate: new Date(2021, 1, 1, 12), - endDate: new Date(2021, 1, 1, 13), + ...appointmentItem, groupId: 1, }], currentView, - currentDate: new Date(2021, 1, 1), + currentDate, groups: ['groupId'], resources: [ { @@ -58,6 +63,7 @@ fixture.disablePageReloads`a11y - appointment` text: 'resource1', id: 1, }], + label: 'Group 1', }, ], }); @@ -65,25 +71,25 @@ fixture.disablePageReloads`a11y - appointment` test(`appointment should have correct aria-label with multiple group (${currentView})`, async (t) => { const scheduler = new Scheduler('#container'); - - const attrs = await scheduler.getAppointment('App 1').element.attributes; + const appointment = scheduler.getAppointment('App 1'); await t - .expect(attrs['aria-roledescription']) - .eql('February 1, 2021, Group: resource11, resource21, '); + .expect(appointment.getAriaLabel()) + .eql('App 1: February 1, 2021, 12:00 PM - 1:00 PM') + .expect(await appointment.getAriaDescription()) + .eql('Group: resource11, resource21; Group 1: resource11; Group 2: resource21, resource22'); await a11yCheck(t, checkOptions, '#container'); }).before(async () => { await createWidget('dxScheduler', { + timeZone: 'UTC', dataSource: [{ - text: 'App 1', - startDate: new Date(2021, 1, 1, 12), - endDate: new Date(2021, 1, 1, 13), + ...appointmentItem, groupId1: 1, - groupId2: 1, + groupId2: [1, 2], }], currentView, - currentDate: new Date(2021, 1, 1), + currentDate, groups: ['groupId1', 'groupId2'], resources: [ { @@ -92,13 +98,18 @@ fixture.disablePageReloads`a11y - appointment` text: 'resource11', id: 1, }], + label: 'Group 1', }, { fieldExpr: 'groupId2', dataSource: [{ text: 'resource21', id: 1, + }, { + text: 'resource22', + id: 2, }], + label: 'Group 2', }, ], }); @@ -125,7 +136,7 @@ fixture.disablePageReloads`a11y - appointment` }, ], currentView, - currentDate: new Date(2021, 3, 29), + currentDate: new Date('2021-04-29T18:30:00.000Z'), startDayHour: 9, }); }); @@ -133,15 +144,10 @@ fixture.disablePageReloads`a11y - appointment` test('appointments should have right role', async (t) => { const scheduler = new Scheduler('#container'); const appt = scheduler.getAppointment('Website Re-Design Plan'); - const contentId = await appt.element.find('.dx-scheduler-appointment-content').getAttribute('id'); await t .expect(appt.element.getAttribute('role')) - .eql('application'); - - await t - .expect(appt.element.getAttribute('aria-describedby')) - .eql(contentId); + .eql('button'); await t .expect(appt.element.getAttribute('aria-activedescendant')) @@ -159,7 +165,7 @@ fixture.disablePageReloads`a11y - appointment` }, ], currentView, - currentDate: new Date(2021, 3, 29), + currentDate: new Date('2021-04-29T18:30:00.000Z'), startDayHour: 9, }); }); @@ -168,22 +174,22 @@ fixture.disablePageReloads`a11y - appointment` [ { currentView: 'week', - startDate: new Date(2021, 1, 1, 12), - endDate: new Date(2021, 1, 3, 13), + startDate: Date.UTC(2021, 1, 1, 12), + endDate: Date.UTC(2021, 1, 3, 13), labels: [ - 'February 1, 2021 - February 3, 2021 (1/3), ', - 'February 1, 2021 - February 3, 2021 (2/3), ', - 'February 1, 2021 - February 3, 2021 (3/3), ', + 'App 1: February 1, 2021, 12:00 PM - February 3, 2021, 1:00 PM (1/3)', + 'App 1: February 1, 2021, 12:00 PM - February 3, 2021, 1:00 PM (2/3)', + 'App 1: February 1, 2021, 12:00 PM - February 3, 2021, 1:00 PM (3/3)', ], }, { currentView: 'month', - startDate: new Date(2021, 1, 1, 12), - endDate: new Date(2021, 1, 17, 13), + startDate: Date.UTC(2021, 1, 1, 12), + endDate: Date.UTC(2021, 1, 17, 13), labels: [ - 'February 1, 2021 - February 17, 2021 (1/3), ', - 'February 1, 2021 - February 17, 2021 (2/3), ', - 'February 1, 2021 - February 17, 2021 (3/3), ', + 'App 1: February 1, 2021, 12:00 PM - February 17, 2021, 1:00 PM (1/3)', + 'App 1: February 1, 2021, 12:00 PM - February 17, 2021, 1:00 PM (2/3)', + 'App 1: February 1, 2021, 12:00 PM - February 17, 2021, 1:00 PM (3/3)', ], }, ].forEach(({ @@ -193,10 +199,9 @@ fixture.disablePageReloads`a11y - appointment` const scheduler = new Scheduler('#container'); const apptLabels = await Promise.all([0, 1, 2].map( - async (i) => { - const appt = scheduler.getAppointment('App 1', i); - const attrs = await appt.element.attributes; - return attrs['aria-roledescription']; + (i) => { + const appointment = scheduler.getAppointment('App 1', i); + return appointment.getAriaLabel(); }, )); @@ -207,6 +212,7 @@ fixture.disablePageReloads`a11y - appointment` await a11yCheck(t, checkOptions, '#container'); }).before(async () => { await createWidget('dxScheduler', { + timeZone: 'UTC', dataSource: [{ text: 'App 1', startDate, @@ -214,7 +220,7 @@ fixture.disablePageReloads`a11y - appointment` }], allDayPanelMode: 'hidden', currentView, - currentDate: new Date(2021, 1, 1), + currentDate: Date.UTC(2021, 1, 1), }); }); }); @@ -409,11 +415,11 @@ test('Scheduler a11y: Appointment collector button doesn\'t have info about date test('appointment aria label should contain date with right timezone', async (t) => { const scheduler = new Scheduler('#container'); - const appt = scheduler.getAppointmentByIndex(0); + const appointment = scheduler.getAppointmentByIndex(0); await t - .expect(appt.element.getAttribute('aria-roledescription')) - .eql('March 29, 2021, '); + .expect(appointment.getAriaLabel()) + .eql('Install New Router in Dev Room: March 29, 2021, 2:30 PM - 3:30 PM'); await a11yCheck(t, checkOptions, '#container'); }).before(async () => { diff --git a/e2e/testcafe-devextreme/tests/scheduler/common/dragAndDrop/T1235433.ts b/e2e/testcafe-devextreme/tests/scheduler/common/dragAndDrop/T1235433.ts index 3eaa76334bfd..e05e9fc92648 100644 --- a/e2e/testcafe-devextreme/tests/scheduler/common/dragAndDrop/T1235433.ts +++ b/e2e/testcafe-devextreme/tests/scheduler/common/dragAndDrop/T1235433.ts @@ -7,91 +7,7 @@ import { scrollTo } from '../../helpers/utils'; fixture.disablePageReloads`Scheduler Drag-and-Drop inside Group` .page(url(__dirname, '../../../container.html')); -const dragAppointmentByCircle = async ( - t: TestController, - appointment: Appointment, - description: string[], - times: string[], -) => { - await t.drag(appointment.element, -200, 0) - .expect(appointment.date.roleDescription) - .contains(description[0]) - .expect(appointment.date.time) - .eql(times[0]); - - await t.drag(appointment.element, 0, 200) - .expect(appointment.date.roleDescription) - .contains(description[1]) - .expect(appointment.date.time) - .eql(times[1]); - - await t.drag(appointment.element, 200, 0) - .expect(appointment.date.roleDescription) - .contains(description[2]) - .expect(appointment.date.time) - .eql(times[2]); - - await t.drag(appointment.element, 0, -200) - .expect(appointment.date.roleDescription) - .contains(description[3]) - .expect(appointment.date.time) - .eql(times[3]); -}; -const appointmentDescriptions = [ - 'February 2, 2021, Group: Low Priority', - 'February 2, 2021, Group: High Priority', - 'February 2, 2021, Group: High Priority', - 'February 2, 2021, Group: Low Priority', -]; -const appointment1Times = ['9:00 AM - 10:00 AM', '9:00 AM - 10:00 AM', '10:00 AM - 11:00 AM', '10:00 AM - 11:00 AM']; -const appointment2Times = ['4:00 PM - 5:15 PM', '4:00 PM - 5:15 PM', '5:00 PM - 6:15 PM', '5:00 PM - 6:15 PM']; - -test('T1235433: Scheduler - Drag-n-Drop works inside the group with virtual scrolling', async (t) => { - const scheduler = new Scheduler('#container'); - - await t.expect(scheduler.element.exists).ok(); - - await t.debug(); - - await dragAppointmentByCircle(t, scheduler.getAppointment('Book 1'), appointmentDescriptions, appointment1Times); - await scrollTo(1400, 0); - await dragAppointmentByCircle(t, scheduler.getAppointment('Book 2'), appointmentDescriptions, appointment2Times); - - await t.click(scheduler.toolbar.viewSwitcher.getButton('Timeline Work Week').element) - .expect(scheduler.checkViewType('timelineWorkWeek')) - .ok(); - await scrollTo(2400, 0); - await dragAppointmentByCircle(t, scheduler.getAppointment('Book 1'), appointmentDescriptions, appointment1Times); - await scrollTo(3400, 0); - await dragAppointmentByCircle(t, scheduler.getAppointment('Book 2'), appointmentDescriptions, appointment2Times); - - await t.click(scheduler.toolbar.viewSwitcher.getButton('Timeline Month').element) - .expect(scheduler.checkViewType('timelineMonth')) - .ok(); - await dragAppointmentByCircle(t, scheduler.getAppointment('Book 1'), [ - 'February 1, 2021, Group: Low Priority', - 'February 1, 2021, Group: High Priority', - 'February 2, 2021, Group: High Priority', - 'February 2, 2021, Group: Low Priority', - ], [ - appointment1Times[2], - appointment1Times[2], - appointment1Times[2], - appointment1Times[2], - ]); - await scrollTo(1000, 0); - await dragAppointmentByCircle(t, scheduler.getAppointment('Book 3'), [ - 'February 7, 2021, Group: Low Priority', - 'February 7, 2021, Group: High Priority', - 'February 8, 2021, Group: High Priority', - 'February 8, 2021, Group: Low Priority', - ], [ - appointment2Times[2], - appointment2Times[2], - appointment2Times[2], - appointment2Times[2], - ]); -}).before(async () => createWidget('dxScheduler', { +const createScheduler = (view: string) => createWidget('dxScheduler', { timeZone: 'America/Los_Angeles', dataSource: [ { @@ -111,8 +27,8 @@ test('T1235433: Scheduler - Drag-n-Drop works inside the group with virtual scro priority: 1, }, ], - views: ['timelineDay', 'timelineWorkWeek', 'timelineMonth'], - currentView: 'timelineDay', + views: [view], + currentView: view, currentDate: new Date('2021-02-02T17:00:00.000Z'), firstDayOfWeek: 0, scrolling: { mode: 'virtual' }, @@ -130,4 +46,78 @@ test('T1235433: Scheduler - Drag-n-Drop works inside the group with virtual scro label: 'Priority', }], height: 580, -})); +}); +const dragAppointmentByCircle = async ( + t: TestController, + appointment: Appointment, + label: string[], + description: string[], +) => { + await t.drag(appointment.element, -200, 0); + await t.expect(appointment.getAriaLabel()) + .contains(label[0]) + .expect(await appointment.getAriaDescription()) + .contains(description[0]); + + await t.drag(appointment.element, 0, 200); + await t.expect(appointment.getAriaLabel()) + .contains(label[1]) + .expect(await appointment.getAriaDescription()) + .contains(description[1]); + + await t.drag(appointment.element, 200, 0); + await t.expect(appointment.getAriaLabel()) + .contains(label[2]) + .expect(await appointment.getAriaDescription()) + .contains(description[2]); + + await t.drag(appointment.element, 0, -200); + await t.expect(appointment.getAriaLabel()) + .contains(label[3]) + .expect(await appointment.getAriaDescription()) + .contains(description[3]); +}; +const appointmentDescriptions = ['Group: Low Priority', 'Group: High Priority', 'Group: High Priority', 'Group: Low Priority']; +const appointment1Times = ['9:00 AM - 10:00 AM', '9:00 AM - 10:00 AM', '10:00 AM - 11:00 AM', '10:00 AM - 11:00 AM']; +const appointment2Times = ['4:00 PM - 5:15 PM', '4:00 PM - 5:15 PM', '5:00 PM - 6:15 PM', '5:00 PM - 6:15 PM']; + +test('T1235433: Scheduler - Drag-n-Drop works inside the group with virtual scrolling (timelineDay)', async (t) => { + const scheduler = new Scheduler('#container'); + + await t.expect(scheduler.element.exists).ok(); + + await dragAppointmentByCircle(t, scheduler.getAppointment('Book 1'), appointment1Times, appointmentDescriptions); + await scrollTo(1400, 0); + await dragAppointmentByCircle(t, scheduler.getAppointment('Book 2'), appointment2Times, appointmentDescriptions); +}).before(async () => createScheduler('timelineDay')); + +test('T1235433: Scheduler - Drag-n-Drop works inside the group with virtual scrolling (timelineWorkWeek)', async (t) => { + const scheduler = new Scheduler('#container'); + + await t.expect(scheduler.element.exists).ok(); + + await scrollTo(2400, 0); + await dragAppointmentByCircle(t, scheduler.getAppointment('Book 1'), appointment1Times, appointmentDescriptions); + await scrollTo(3400, 0); + await dragAppointmentByCircle(t, scheduler.getAppointment('Book 2'), appointment2Times, appointmentDescriptions); +}).before(async () => createScheduler('timelineWorkWeek')); + +test('T1235433: Scheduler - Drag-n-Drop works inside the group with virtual scrolling (timelineMonth)', async (t) => { + const scheduler = new Scheduler('#container'); + + await t.expect(scheduler.element.exists).ok(); + + await dragAppointmentByCircle(t, scheduler.getAppointment('Book 1'), [ + 'February 1, 2021', + 'February 1, 2021', + 'February 2, 2021', + 'February 2, 2021', + ], appointmentDescriptions); + await scrollTo(1000, 0); + await dragAppointmentByCircle(t, scheduler.getAppointment('Book 3'), [ + 'February 7, 2021', + 'February 7, 2021', + 'February 8, 2021', + 'February 8, 2021', + ], appointmentDescriptions); +}).before(async () => createScheduler('timelineMonth')); diff --git a/packages/devextreme/js/__internal/scheduler/__mock__/timezone_calculator.mock.ts b/packages/devextreme/js/__internal/scheduler/__mock__/timezone_calculator.mock.ts new file mode 100644 index 000000000000..8daf1dcafbe0 --- /dev/null +++ b/packages/devextreme/js/__internal/scheduler/__mock__/timezone_calculator.mock.ts @@ -0,0 +1,3 @@ +import { createTimeZoneCalculator } from '../r1/timezone_calculator/utils'; + +export const mockTimeZoneCalculator = createTimeZoneCalculator('UTC'); diff --git a/packages/devextreme/js/__internal/scheduler/appointments/appointment/agenda_appointment.ts b/packages/devextreme/js/__internal/scheduler/appointments/appointment/agenda_appointment.ts index 2cc9cda00db2..6ca860237964 100644 --- a/packages/devextreme/js/__internal/scheduler/appointments/appointment/agenda_appointment.ts +++ b/packages/devextreme/js/__internal/scheduler/appointments/appointment/agenda_appointment.ts @@ -1,6 +1,5 @@ import type { dxElementWrapper } from '@js/core/renderer'; import $ from '@js/core/renderer'; -import type { ResourceProcessor } from '@ts/scheduler/resources/resource_processor'; import { APPOINTMENT_CONTENT_CLASSES, @@ -12,11 +11,6 @@ export class AgendaAppointment extends Appointment { return this.$element().find(`.${APPOINTMENT_CONTENT_CLASSES.AGENDA_MARKER}`); } - get resourceProcessor(): ResourceProcessor { - const getResourceProcessor = this.option('getResourceProcessor') as () => ResourceProcessor; - return getResourceProcessor(); - } - _renderResourceList(): void { // eslint-disable-next-line no-void void this.resourceProcessor diff --git a/packages/devextreme/js/__internal/scheduler/appointments/appointment/m_appointment.ts b/packages/devextreme/js/__internal/scheduler/appointments/appointment/m_appointment.ts index c3f99b67950f..2a1eb496e574 100644 --- a/packages/devextreme/js/__internal/scheduler/appointments/appointment/m_appointment.ts +++ b/packages/devextreme/js/__internal/scheduler/appointments/appointment/m_appointment.ts @@ -2,20 +2,19 @@ import { move } from '@js/common/core/animation/translator'; import eventsEngine from '@js/common/core/events/core/events_engine'; import pointerEvents from '@js/common/core/events/pointer'; import { addNamespace } from '@js/common/core/events/utils/index'; -import dateLocalization from '@js/common/core/localization/date'; -import messageLocalization from '@js/common/core/localization/message'; import registerComponent from '@js/core/component_registrator'; import DOMComponent from '@js/core/dom_component'; import Guid from '@js/core/guid'; import type { dxElementWrapper } from '@js/core/renderer'; import $ from '@js/core/renderer'; import { extend } from '@js/core/utils/extend'; -import { isDefined } from '@js/core/utils/type'; import Resizable from '@js/ui/resizable'; +import type { ResourceProcessor } from '@ts/scheduler/resources/resource_processor'; import { hide, show } from '@ts/ui/tooltip/m_tooltip'; import { ALL_DAY_APPOINTMENT_CLASS, + APPOINTMENT_CONTENT_CLASSES, APPOINTMENT_DRAG_SOURCE_CLASS, APPOINTMENT_HAS_RESOURCE_COLOR_CLASS, DIRECTION_APPOINTMENT_CLASSES, @@ -27,6 +26,12 @@ import { } from '../../m_classes'; import { getRecurrenceProcessor } from '../../m_recurrence'; import type { AppointmentDataAccessor } from '../../utils'; +import type { AppointmentProperties } from './m_types'; +import { + getAriaDescription, + getAriaLabel, + getReducedIconTooltip, +} from './text_utils'; const DEFAULT_HORIZONTAL_HANDLES = 'left right'; const DEFAULT_VERTICAL_HANDLES = 'top bottom'; @@ -34,7 +39,7 @@ const DEFAULT_VERTICAL_HANDLES = 'top bottom'; const REDUCED_APPOINTMENT_POINTERENTER_EVENT_NAME = addNamespace(pointerEvents.enter, 'dxSchedulerAppointment'); const REDUCED_APPOINTMENT_POINTERLEAVE_EVENT_NAME = addNamespace(pointerEvents.leave, 'dxSchedulerAppointment'); -export class Appointment extends DOMComponent { +export class Appointment extends DOMComponent { get coloredElement(): any { return this.$element(); } @@ -43,8 +48,12 @@ export class Appointment extends DOMComponent { return this.option('data'); } + get resourceProcessor(): ResourceProcessor { + return this.option('getResourceProcessor')(); + } + get dataAccessors(): AppointmentDataAccessor { - return this.option('dataAccessors') as AppointmentDataAccessor; + return this.option('dataAccessors'); } _getDefaultOptions() { @@ -69,7 +78,7 @@ export class Appointment extends DOMComponent { } notifyObserver(subject, args) { - const observer: any = this.option('observer'); + const observer = this.option('observer'); if (observer) { observer.fire(subject, args); } @@ -77,7 +86,7 @@ export class Appointment extends DOMComponent { // eslint-disable-next-line @typescript-eslint/no-unused-vars invoke(funcName: string) { - const observer: any = this.option('observer'); + const observer = this.option('observer'); if (observer) { return observer.fire.apply(observer, arguments); @@ -152,8 +161,8 @@ export class Appointment extends DOMComponent { this._renderDragSourceClass(); this._renderDirection(); - (this.$element() as any).data('dxAppointmentStartDate', this.option('startDate')); - (this.$element() as any).attr('role', 'application'); + this.$element().data('dxAppointmentStartDate', this.option('startDate')); + this.$element().attr('role', 'button'); this._renderRecurrenceClass(); this._renderResizable(); @@ -167,8 +176,7 @@ export class Appointment extends DOMComponent { groupIndex: this.option('groupIndex'), groups: this.option('groups'), }; - - const deferredColor = (this.option('getAppointmentColor') as any)(appointmentConfig); + const deferredColor = this.option('getAppointmentColor')(appointmentConfig); deferredColor.done((color) => { if (color) { @@ -178,46 +186,22 @@ export class Appointment extends DOMComponent { }); } - _getGroupText() { - const groupTexts = this.option('groupTexts') as string[]; - if (!groupTexts?.length) { - return ''; - } - - const groupText = groupTexts.join(', '); - // @ts-expect-error - return messageLocalization.format('dxScheduler-appointmentAriaLabel-group', groupText); - } - - _getDateText(): string { - const startDateText = this._localizeDate(this._getStartDate()); - const endDateText = this._localizeDate(this._getEndDate()); - - const dateText = startDateText === endDateText - ? `${startDateText}` - : `${startDateText} - ${endDateText}`; - - // @ts-expect-error - const { partIndex, partTotalCount } = this.option(); - const partText = isDefined(partIndex) ? ` (${partIndex + 1}/${partTotalCount})` : ''; - - return `${dateText}${partText}`; - } - _renderAriaLabel(): void { const $element: dxElementWrapper = this.$element(); - const ariaLabel = [ - this._getDateText(), - this._getGroupText(), - ] - .filter((label) => !!label) - .join(', '); - $element.attr('aria-roledescription', `${ariaLabel}, `); - - const id = `dx-${new Guid()}`; - - $element.attr('aria-describedby', id); - $element.find('.dx-item-content').attr('id', id); + $element.attr('aria-label', getAriaLabel(this.option())); + + // eslint-disable-next-line no-void + void getAriaDescription(this.option()) + .then((text) => { + if (text) { + const id = `dx-${new Guid()}`; + + $element.attr('aria-describedby', id); + $element.find(`.${APPOINTMENT_CONTENT_CLASSES.ARIA_DESCRIPTION}`) + .html(text) + .attr('id', id); + } + }); } _renderAppointmentGeometry(): void { @@ -256,24 +240,16 @@ export class Appointment extends DOMComponent { this._renderAppointmentReducedIcon(); } - _localizeDate(date) { - return `${dateLocalization.format(date, 'monthAndDay')}, ${dateLocalization.format(date, 'year')}`; - } - _renderAppointmentReducedIcon() { const $icon = $('
') .addClass(REDUCED_APPOINTMENT_ICON) .appendTo(this.$element()); - const endDate = this._getEndDate(); - const tooltipLabel = messageLocalization.format('dxScheduler-editorLabelEndDate'); - const tooltipText = [tooltipLabel, ': ', this._localizeDate(endDate)].join(''); - eventsEngine.off($icon, REDUCED_APPOINTMENT_POINTERENTER_EVENT_NAME); eventsEngine.on($icon, REDUCED_APPOINTMENT_POINTERENTER_EVENT_NAME, () => { show({ target: $icon, - content: tooltipText, + content: getReducedIconTooltip(this.option()), }); }); eventsEngine.off($icon, REDUCED_APPOINTMENT_POINTERLEAVE_EVENT_NAME); @@ -282,30 +258,6 @@ export class Appointment extends DOMComponent { }); } - _getDate(propName: 'endDate' | 'startDate') { - const result = this.dataAccessors.get(propName, this.rawAppointment); - - if (!result) { - return result; - } - - const date = new Date(result); - const timeZoneCalculator = this.option('timeZoneCalculator') as any; - - // eslint-disable-next-line @typescript-eslint/no-unsafe-return - return timeZoneCalculator - ? timeZoneCalculator.createDate(date, { path: 'toGrid' }) - : date; - } - - _getEndDate() { - return this._getDate('endDate'); - } - - _getStartDate() { - return this._getDate('startDate'); - } - _renderAllDayClass() { (this.$element() as any).toggleClass(ALL_DAY_APPOINTMENT_CLASS, !!this.option('allDay')); } @@ -355,4 +307,4 @@ export class Appointment extends DOMComponent { } } -registerComponent('dxSchedulerAppointment', Appointment); +registerComponent('dxSchedulerAppointment', Appointment as any); diff --git a/packages/devextreme/js/__internal/scheduler/appointments/appointment/m_types.ts b/packages/devextreme/js/__internal/scheduler/appointments/appointment/m_types.ts new file mode 100644 index 000000000000..bd862ef712ec --- /dev/null +++ b/packages/devextreme/js/__internal/scheduler/appointments/appointment/m_types.ts @@ -0,0 +1,41 @@ +import type { Orientation } from '@js/common'; +import type { TimeZoneCalculator } from '@ts/scheduler/r1/timezone_calculator/calculator'; +import type { ResourceProcessor } from '@ts/scheduler/resources/resource_processor'; +import type { SafeAppointment } from '@ts/scheduler/types'; +import type { AppointmentDataAccessor } from '@ts/scheduler/utils/data_accessor/appointment_data_accessor'; + +export interface LoadedResource { + data: Record[]; + items: { + id: number | string; + text: string; + }[]; + name: string; +} +export interface AppointmentProperties extends Record { + data: SafeAppointment; + groupIndex?: number; + groupTexts: string[]; + observer: any; + geometry: any; + direction: Orientation; + allowResize: boolean; + allowDrag: boolean; + allDay: boolean; + reduced: string; + isCompact: boolean; + startDate: Date; + cellWidth: number; + cellHeight: number; + resizableConfig: Record; + groups: any[]; + partIndex?: number; + partTotalCount: number; + + dataAccessors: AppointmentDataAccessor; + timeZoneCalculator: TimeZoneCalculator; + getAppointmentColor: (config: any) => any; + getResourceDataAccessors: () => any; + getResourceProcessor: () => ResourceProcessor; + getResizableStep: () => number; +} diff --git a/packages/devextreme/js/__internal/scheduler/appointments/appointment/text_utils.test.ts b/packages/devextreme/js/__internal/scheduler/appointments/appointment/text_utils.test.ts new file mode 100644 index 000000000000..19a8b60cebeb --- /dev/null +++ b/packages/devextreme/js/__internal/scheduler/appointments/appointment/text_utils.test.ts @@ -0,0 +1,133 @@ +import { + afterAll, describe, expect, it, jest, +} from '@jest/globals'; +import { mockAppointmentDataAccessor } from '@ts/scheduler/__mock__/appointment_data_accessor.mock'; +import { mockTimeZoneCalculator } from '@ts/scheduler/__mock__/timezone_calculator.mock'; + +import { + getAriaDescription, getAriaLabel, getGroupTexts, getReducedIconTooltip, +} from './text_utils'; + +const getAppointmentResourcesValues = jest.fn(); +const options = { + dataAccessors: mockAppointmentDataAccessor, + timeZoneCalculator: mockTimeZoneCalculator, + getResourceProcessor: () => ({ + getAppointmentResourcesValues, + }), +} as any; + +describe('Appointment text utils', () => { + describe('getAriaLabel', () => { + it('should return text for all day appointment', () => { + expect(getAriaLabel({ + ...options, + data: { + allDay: true, + text: 'Appointment name', + startDate: Date.UTC(2025, 2, 10, 10), + endDate: Date.UTC(2025, 2, 10, 10, 30), + }, + })).toBe('Appointment name: March 10, 2025, All day'); + }); + + it('should return text for one day appointment', () => { + expect(getAriaLabel({ + ...options, + data: { + text: 'Appointment name', + startDate: Date.UTC(2025, 2, 10, 10), + endDate: Date.UTC(2025, 2, 10, 10, 30), + }, + })).toBe('Appointment name: March 10, 2025, 10:00 AM - 10:30 AM'); + }); + + it('should return text for a part of long appointment', () => { + expect(getAriaLabel({ + ...options, + data: { + text: 'Appointment name', + startDate: Date.UTC(2025, 2, 10, 10), + endDate: Date.UTC(2025, 2, 11, 10, 30), + }, + partIndex: 0, + partTotalCount: 2, + })).toBe('Appointment name: March 10, 2025, 10:00 AM - March 11, 2025, 10:30 AM (1/2)'); + }); + }); + + describe('getReducedIconTooltip', () => { + it('should return text with end date', () => { + expect(getReducedIconTooltip({ + ...options, + data: { + text: 'Appointment name', + startDate: Date.UTC(2025, 2, 10, 10), + endDate: Date.UTC(2025, 2, 11, 10, 30), + }, + })).toBe('End Date: March 11, 2025'); + }); + }); + + describe('getGroupTexts', () => { + it('should return groups for single grouping', () => { + expect(getGroupTexts(1, [ + { items: [{ text: 'Room 1' }, { text: 'Room 2' }], name: 'roomId' }, + ] as any)).toEqual(['Room 1']); + }); + it('should return groups for multiple grouping', () => { + expect(getGroupTexts(3, [ + { items: [{ text: 'Samantha Bright' }, { text: 'John Heart' }], name: 'assigneeId' }, + { items: [{ text: 'Room 1' }, { text: 'Room 2' }], name: 'roomId' }, + ] as any)).toEqual(['Samantha Bright', 'Room 1']); + }); + }); + + describe('getAriaDescription', () => { + afterAll(() => { + jest.clearAllMocks(); + }); + + it('should return text with one resource', async () => { + getAppointmentResourcesValues.mockReturnValue(Promise.resolve([ + { label: 'Assignee', values: ['Samantha Bright'] }, + ])); + expect(await getAriaDescription({ + ...options, + groupTexts: [], + })).toBe('Assignee: Samantha Bright'); + }); + + it('should return text with multiple resources', async () => { + getAppointmentResourcesValues.mockReturnValue(Promise.resolve([ + { label: 'Assignee', values: ['Samantha Bright', 'John Heart'] }, + { label: 'Room', values: ['Room 1'] }, + ])); + expect(await getAriaDescription({ + ...options, + groupTexts: [], + })).toBe('Assignee: Samantha Bright, John Heart; Room: Room 1'); + }); + + it('should return text with group', async () => { + getAppointmentResourcesValues.mockReturnValue([]); + expect(await getAriaDescription({ + ...options, + groupIndex: 0, + groupTexts: ['Samantha Bright'], + })).toBe('Group: Samantha Bright'); + }); + + it('should return text with multiple groups and resources', async () => { + getAppointmentResourcesValues.mockReturnValue(Promise.resolve([ + { label: 'Assignee', values: ['Samantha Bright'] }, + { label: 'Room', values: ['Room 1', 'Room 2'] }, + ])); + expect(await getAriaDescription({ + ...options, + groupIndex: 1, + groupTexts: ['Samantha Bright', 'Room 1'], + })).toBe('Group: Samantha Bright, Room 1; Assignee: Samantha Bright; Room: Room 1, Room 2'); + }); + }); +}); diff --git a/packages/devextreme/js/__internal/scheduler/appointments/appointment/text_utils.ts b/packages/devextreme/js/__internal/scheduler/appointments/appointment/text_utils.ts new file mode 100644 index 000000000000..59308c4c9a16 --- /dev/null +++ b/packages/devextreme/js/__internal/scheduler/appointments/appointment/text_utils.ts @@ -0,0 +1,112 @@ +import dateLocalization from '@js/common/core/localization/date'; +import messageLocalization from '@js/common/core/localization/message'; +import { isDefined } from '@js/core/utils/type'; +import { PathTimeZoneConversion } from '@ts/scheduler/r1/timezone_calculator/const'; + +import { getPathToLeaf } from '../../resources/m_utils'; +import type { AppointmentProperties, LoadedResource } from './m_types'; + +const localizeDate = (date: Date): string => `${dateLocalization.format(date, 'monthAndDay')}, ${dateLocalization.format(date, 'year')}`; + +const localizeTime = (date: Date): string => `${dateLocalization.format(date, 'shorttime')}`; + +const getDate = (options: AppointmentProperties, propName: 'endDate' | 'startDate'): Date => { + const result = options.dataAccessors.get(propName, options.data); + + if (!result) { + return result; + } + + const date = new Date(result); + const gridDate = options.timeZoneCalculator?.createDate( + date, + { path: PathTimeZoneConversion.fromSourceToGrid }, + ); + + return gridDate ?? date; +}; + +const getDateText = (options: AppointmentProperties): string => { + const startDate = getDate(options, 'startDate'); + const endDate = getDate(options, 'endDate'); + const startDateText = localizeDate(startDate); + const endDateText = localizeDate(endDate); + const startTimeText = localizeTime(startDate); + const endTimeText = localizeTime(endDate); + const isAllDay = Boolean(options.dataAccessors.get('allDay', options.data)); + const allDayText = messageLocalization.format('dxScheduler-allDay'); + + if (startDateText === endDateText) { + return isAllDay + ? `${startDateText}, ${allDayText}` + : `${startDateText}, ${startTimeText} - ${endTimeText}`; + } + + return isAllDay + ? `${startDateText} - ${endDateText}, ${allDayText}` + : `${startDateText}, ${startTimeText} - ${endDateText}, ${endTimeText}`; +}; + +const getPartsText = ( + { partIndex, partTotalCount }: AppointmentProperties, +): string => (isDefined(partIndex) ? ` (${partIndex + 1}/${partTotalCount})` : ''); + +export const getAriaLabel = (options: AppointmentProperties): string => { + const name = options.dataAccessors.get('text', options.data) ?? ''; + const dates = getDateText(options); + const parts = getPartsText(options); + + return `${name}: ${dates}${parts}`; +}; + +export const getReducedIconTooltip = (options: AppointmentProperties): string => { + const tooltipLabel = messageLocalization.format('dxScheduler-editorLabelEndDate'); + const endDateText = localizeDate(getDate(options, 'endDate')); + + return `${tooltipLabel}: ${endDateText}`; +}; + +export const getGroupTexts = ( + groupIndex: number, + loadedResources: LoadedResource[], +): string[] => { + if (!loadedResources?.length) { + return []; + } + + const idPath: (string | number)[] = getPathToLeaf(groupIndex, loadedResources); + const textPath = idPath.map( + (id, index) => loadedResources[index].items.find( + (item) => item.id === id, + )?.text, + ).filter(Boolean); + + return textPath as string[]; +}; + +const getGroupText = (options: AppointmentProperties): string => { + if (!options.groupTexts.length) { + return ''; + } + + const groupText = options.groupTexts.join(', '); + // @ts-ignore @ts-expect-error + return messageLocalization.format('dxScheduler-appointmentAriaLabel-group', groupText); +}; + +const getResourceText = async (options: AppointmentProperties): Promise => { + const resourceProcessor = options.getResourceProcessor(); + const list = await resourceProcessor.getAppointmentResourcesValues(options.data); + + return list.map((item) => `${item.label}: ${item.values.join(', ')}`); +}; + +export const getAriaDescription = async (options: AppointmentProperties): Promise => { + const resources = await getResourceText(options); + const texts = [ + getGroupText(options), + ...resources, + ].filter(Boolean); + + return texts.join('; '); +}; diff --git a/packages/devextreme/js/__internal/scheduler/appointments/m_appointment_collection.ts b/packages/devextreme/js/__internal/scheduler/appointments/m_appointment_collection.ts index 8c5618e86e59..8ee502129676 100644 --- a/packages/devextreme/js/__internal/scheduler/appointments/m_appointment_collection.ts +++ b/packages/devextreme/js/__internal/scheduler/appointments/m_appointment_collection.ts @@ -30,11 +30,11 @@ import { createAppointmentAdapter } from '../m_appointment_adapter'; import { APPOINTMENT_CONTENT_CLASSES, APPOINTMENT_DRAG_SOURCE_CLASS, APPOINTMENT_ITEM_CLASS } from '../m_classes'; import { getRecurrenceProcessor } from '../m_recurrence'; import timeZoneUtils from '../m_utils_time_zone'; -import { getPathToLeaf } from '../resources/m_utils'; import type { AppointmentViewModel } from '../types'; import type { AppointmentDataAccessor } from '../utils'; import { AgendaAppointment } from './appointment/agenda_appointment'; import { Appointment } from './appointment/m_appointment'; +import { getGroupTexts } from './appointment/text_utils'; import { getAppointmentTakesSeveralDays, sortAppointmentsByStartDate } from './data_provider/m_utils'; import { createAgendaAppointmentLayout, createAppointmentLayout } from './m_appointment_layout'; import { getAppointmentDateRange } from './resizing/m_core'; @@ -433,6 +433,11 @@ class SchedulerAppointments extends CollectionWidget { ? createAgendaAppointmentLayout(formatText, config) : createAppointmentLayout(formatText, config)); + $container.parent().prepend( + $('') + .addClass(APPOINTMENT_CONTENT_CLASSES.ARIA_DESCRIPTION) + .attr('hidden', true), + ); if (!this.isAgendaView) { $container.parent().prepend( $('
').addClass(APPOINTMENT_CONTENT_CLASSES.STRIP), @@ -564,20 +569,6 @@ class SchedulerAppointments extends CollectionWidget { this._renderAppointment(args.itemElement, this._currentAppointmentSettings); } - _getGroupTexts(groupIndex, loadedResources) { - if (!loadedResources?.length) { - return []; - } - const idPath = getPathToLeaf(groupIndex, loadedResources); - const textPath = idPath.map( - (id, index) => loadedResources[index].items - .find( - (item) => item.id === id, - ).text, - ); - return textPath; - } - _renderAppointment(element, settings) { element.data(APPOINTMENT_SETTINGS_KEY, settings); @@ -603,7 +594,7 @@ class SchedulerAppointments extends CollectionWidget { const config: any = { data: rawAppointment, groupIndex: settings.groupIndex, - groupTexts: this._getGroupTexts(settings.groupIndex, this.option('getLoadedResources')()), + groupTexts: getGroupTexts(settings.groupIndex, this.option('getLoadedResources')()), observer: this.option('observer'), geometry, direction: settings.direction || 'vertical', @@ -620,20 +611,18 @@ class SchedulerAppointments extends CollectionWidget { partIndex: settings.partIndex, partTotalCount: settings.partTotalCount, + dataAccessors: this.dataAccessors, + timeZoneCalculator: this.option('timeZoneCalculator'), getAppointmentColor: this.option('getAppointmentColor'), getResourceDataAccessors: this.option('getResourceDataAccessors'), getResourceProcessor: this.option('getResourceProcessor'), - timeZoneCalculator: this.option('timeZoneCalculator'), + getResizableStep: this.option('getResizableStep'), }; (this as any)._createComponent( element, this.isAgendaView ? AgendaAppointment : Appointment, - { - ...config, - dataAccessors: this.dataAccessors, - getResizableStep: this.option('getResizableStep'), - }, + config, ); } } diff --git a/packages/devextreme/js/__internal/scheduler/m_classes.ts b/packages/devextreme/js/__internal/scheduler/m_classes.ts index 3f1ecbca5eb6..bc75e49b2287 100644 --- a/packages/devextreme/js/__internal/scheduler/m_classes.ts +++ b/packages/devextreme/js/__internal/scheduler/m_classes.ts @@ -21,6 +21,7 @@ export const APPOINTMENT_CONTENT_CLASSES = { APPOINTMENT_TITLE: 'dx-scheduler-appointment-title', APPOINTMENT_DATE: 'dx-scheduler-appointment-content-date', ALL_DAY_CONTENT: 'dx-scheduler-appointment-content-allday', + ARIA_DESCRIPTION: 'dx-scheduler-appointment-aria-description', ITEM: 'dx-scheduler-appointment', STRIP: 'dx-scheduler-appointment-strip', diff --git a/packages/devextreme/js/__internal/scheduler/resources/resource_processor.test.ts b/packages/devextreme/js/__internal/scheduler/resources/resource_processor.test.ts index 778ce1f2fe3a..4b43c5de7f02 100644 --- a/packages/devextreme/js/__internal/scheduler/resources/resource_processor.test.ts +++ b/packages/devextreme/js/__internal/scheduler/resources/resource_processor.test.ts @@ -205,14 +205,16 @@ describe('ResourceProcessor', () => { }, ]); - await processor.getAppointmentResourcesValues({ - ...appointment, - roomId: 1, - }); - await processor.getAppointmentResourcesValues({ - ...appointment, - roomId: 2, - }); + await Promise.all([ + processor.getAppointmentResourcesValues({ + ...appointment, + roomId: 1, + }), + processor.getAppointmentResourcesValues({ + ...appointment, + roomId: 2, + }), + ]); await processor.getAppointmentResourcesValues({ ...appointment, roomId: 3, diff --git a/packages/devextreme/js/__internal/scheduler/resources/resource_processor.ts b/packages/devextreme/js/__internal/scheduler/resources/resource_processor.ts index a64c704162df..97a88c402391 100644 --- a/packages/devextreme/js/__internal/scheduler/resources/resource_processor.ts +++ b/packages/devextreme/js/__internal/scheduler/resources/resource_processor.ts @@ -14,6 +14,7 @@ export interface AppointmentResource { values: string[]; } +// TODO(6): merge it with main resource loading to load resource only once const loadResource = (dataSourceConfig: ResourceConfig['dataSource']): Promise[]> => { if (!dataSourceConfig) { return Promise.resolve([]); diff --git a/packages/devextreme/testing/tests/DevExpress.ui.widgets.scheduler/appointments.tests.js b/packages/devextreme/testing/tests/DevExpress.ui.widgets.scheduler/appointments.tests.js index d518dcc5b3ec..b8003009a991 100644 --- a/packages/devextreme/testing/tests/DevExpress.ui.widgets.scheduler/appointments.tests.js +++ b/packages/devextreme/testing/tests/DevExpress.ui.widgets.scheduler/appointments.tests.js @@ -99,7 +99,9 @@ const createInstance = (options, subscribesConfig) => { timeZoneCalculator: createTimeZoneCalculator(), getResources: () => [], getLoadedResources: () => [], - getResourceProcessor: () => ({}), + getResourceProcessor: () => ({ + getAppointmentResourcesValues: () => [], + }), getAppointmentColor: () => new Deferred(), getResourceDataAccessors: () => createExpressions([]), dataAccessors, @@ -520,7 +522,7 @@ QUnit.module('Appointments', moduleOptions, () => { assert.strictEqual(deltaTime, -1800000, 'Delta time is OK'); }); - QUnit.test('Scheduler appointment should have aria-role \'application\'', function(assert) { + QUnit.test('Scheduler appointment should have aria-role \'button\'', function(assert) { const item = { itemData: { text: 'Appointment 1', @@ -536,7 +538,7 @@ QUnit.module('Appointments', moduleOptions, () => { const $appointment = instance.$element().find('.dx-scheduler-appointment'); - assert.equal($appointment.attr('role'), 'application', 'role is right'); + assert.equal($appointment.attr('role'), 'button', 'role is right'); }); QUnit.test('Split appointment by day', function(assert) { diff --git a/packages/devextreme/testing/tests/DevExpress.ui.widgets.scheduler/integration.resources.tests.js b/packages/devextreme/testing/tests/DevExpress.ui.widgets.scheduler/integration.resources.tests.js index c13dcbc6cc68..aa8a9a4f398f 100644 --- a/packages/devextreme/testing/tests/DevExpress.ui.widgets.scheduler/integration.resources.tests.js +++ b/packages/devextreme/testing/tests/DevExpress.ui.widgets.scheduler/integration.resources.tests.js @@ -426,7 +426,7 @@ QUnit.module('Integration: Resources', moduleConfig, () => { assert.equal(scheduler.instance.$element().find('.dx-scheduler-appointment').length, 2, 'Appointments are OK'); }); - QUnit.test('Resources should be loaded only once to calculate appts color', function(assert) { + QUnit.test('Resources should be loaded only once to calculate appts color and aria-description', function(assert) { const loadStub = sinon.stub().returns([ { text: 'o1', id: 1 }, { text: 'o2', id: 2 } @@ -444,6 +444,11 @@ QUnit.module('Integration: Resources', moduleConfig, () => { startDate: new Date(2015, 4, 26, 5), endDate: new Date(2015, 4, 26, 5, 30), ownerId: 2 + }, { + text: 'c', + startDate: new Date(2015, 4, 26, 6), + endDate: new Date(2015, 4, 26, 6, 30), + ownerId: 2 }] }), currentDate: new Date(2015, 4, 26), @@ -457,7 +462,8 @@ QUnit.module('Integration: Resources', moduleConfig, () => { }] }); - assert.equal(loadStub.callCount, 1, 'Resources are loaded only once'); + // TODO(6): fix it when after resource refactoring + assert.equal(loadStub.callCount, 2, 'Resources are loaded only once'); }); QUnit.test('Paint appts if groups array don\'t contain all resources', function(assert) { @@ -538,7 +544,8 @@ QUnit.module('Integration: Resources', moduleConfig, () => { scheduler.instance.showAppointmentPopup(data[0]); - assert.equal(loadStub.callCount, 1, 'Resources are loaded only once'); + // TODO(6): fix it when after resource refactoring + assert.equal(loadStub.callCount, 2, 'Resources are loaded only once'); assert.equal(byKeyStub.callCount, 0, 'Resources are loaded only once'); }); diff --git a/packages/testcafe-models/scheduler/appointment/index.ts b/packages/testcafe-models/scheduler/appointment/index.ts index 014593872d3b..fe15193c1dfd 100644 --- a/packages/testcafe-models/scheduler/appointment/index.ts +++ b/packages/testcafe-models/scheduler/appointment/index.ts @@ -29,7 +29,7 @@ const CLASS = { export default class Appointment { element: Selector; - date: { time: Promise; roleDescription: Promise }; + date: { time: Promise }; resizableHandle: { left: Selector; right: Selector; top: Selector; bottom: Selector }; @@ -63,7 +63,6 @@ export default class Appointment { this.date = { time: appointmentContentDate.nth(0).innerText, - roleDescription: this.element.getAttribute('aria-roledescription'), }; this.resizableHandle = { @@ -115,4 +114,20 @@ export default class Appointment { getRecurrenceElement(): Selector { return this.element.find(`.${CLASS.appoinmentRecurrenceIcon}`); } + + getAriaLabel(): Promise { + return this.element.getAttribute('aria-label'); + } + + async hasAriaDescription(): Promise { + const id = await this.element.getAttribute('aria-describedby'); + + return Boolean(id); + } + + async getAriaDescription(): Promise { + const id = await this.element.getAttribute('aria-describedby'); + + return this.element.find(`#${id}`).innerText; + } }