Skip to content

Commit 42d80cd

Browse files
authored
add status a11y message to scheduler (DevExpress#29303)
Co-authored-by: Vladimir Bushmanov <[email protected]>
1 parent c2c3ff5 commit 42d80cd

File tree

47 files changed

+563
-138
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

47 files changed

+563
-138
lines changed

e2e/testcafe-devextreme/tests/scheduler/common/a11y/scheduler.ts

Lines changed: 6 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -8,22 +8,18 @@ import { checkOptions } from './axe_options';
88
fixture.disablePageReloads`a11y - appointment`
99
.page(url(__dirname, '../../../container.html'));
1010

11-
test('Scheduler should have right aria attributes', async (t) => {
11+
test('Scheduler should have right aria attributes after view changed', async (t) => {
1212
const scheduler = new Scheduler('#container');
1313

14-
await t.expect(
15-
scheduler.element.getAttribute('aria-label'),
16-
).eql('Scheduler. Month view');
14+
await t.expect(scheduler.element.getAttribute('aria-label')).contains('Scheduler. Month view');
15+
await t.expect(scheduler.getGeneralStatusContainer().textContent).contains('Scheduler. Month view');
1716

18-
await t.expect(
19-
scheduler.element.getAttribute('role'),
20-
).eql('group');
17+
await t.expect(scheduler.element.getAttribute('role')).eql('group');
2118

2219
await scheduler.option('currentView', 'week');
2320

24-
await t.expect(
25-
scheduler.element.getAttribute('aria-label'),
26-
).eql('Scheduler. Week view');
21+
await t.expect(scheduler.element.getAttribute('aria-label')).contains('Scheduler. Week view');
22+
await t.expect(scheduler.getGeneralStatusContainer().textContent).contains('Scheduler. Week view');
2723

2824
await a11yCheck(t, checkOptions, '#container');
2925
}).before(async () => {
Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
import Scheduler from 'devextreme-testcafe-models/scheduler';
2+
import { createWidget } from '../../../../helpers/createWidget';
3+
import url from '../../../../helpers/getPageUrl';
4+
5+
fixture.disablePageReloads`a11y - appointment`
6+
.page(url(__dirname, '../../../container.html'));
7+
8+
const today = '2025-04-30T15:00:00.000Z';
9+
const appointments = [
10+
{
11+
startDate: '2025-04-25T21:30:00.000Z',
12+
endDate: '2025-04-25T23:30:00.000Z',
13+
recurrenceRule: 'FREQ=HOURLY;INTERVAL=15;COUNT=15',
14+
}, {
15+
startDate: '2025-04-30T15:00:00.000Z',
16+
endDate: '2025-04-30T16:00:00.000Z',
17+
}, {
18+
startDate: '2025-04-26T00:30:00.000Z',
19+
endDate: '2025-04-26T02:30:00.000Z',
20+
recurrenceRule: 'FREQ=HOURLY;INTERVAL=15;COUNT=15',
21+
}, {
22+
startDate: '2025-05-02T15:00:00.000Z',
23+
endDate: '2025-05-02T16:00:00.000Z',
24+
},
25+
];
26+
27+
const statusCheck = async (t: TestController, scheduler: Scheduler, status: string) => {
28+
await t.expect(scheduler.element.getAttribute('aria-label')).contains(status);
29+
await t.expect(scheduler.getGeneralStatusContainer().textContent).contains(status);
30+
};
31+
const statusCheckEql = async (t: TestController, scheduler: Scheduler, status: string) => {
32+
await t.expect(scheduler.element.getAttribute('aria-label')).match(new RegExp(status));
33+
await t.expect(scheduler.getGeneralStatusContainer().textContent).match(new RegExp(status));
34+
};
35+
36+
const options = [
37+
['agenda', 'Agenda view: from April 30, 2025 to May 6, 2025', [0, 9, 19]],
38+
['day', 'Day view: April 30, 2025', [0, 3, 5]],
39+
['month', 'Month view: from March 2025 to May 2025', [0, 17, 35]],
40+
['timelineDay', 'Timeline Day view: April 30, 2025', [0, 3, 5]],
41+
['timelineMonth', 'Timeline Month view: April 2025', [0, 11, 21]],
42+
['timelineWeek', 'Timeline Week view: from April 27, 2025 to May 3, 2025', [0, 12, 25]],
43+
['timelineWorkWeek', 'Timeline Work Week view: from April 28, 2025 to May 2, 2025', [0, 9, 18]],
44+
['week', 'Week view: from April 27, 2025 to May 3, 2025', [0, 13, 27]],
45+
['workWeek', 'Work Week view: from April 28, 2025 to May 2, 2025', [0, 10, 20]],
46+
['Two Weeks', 'Two Weeks view: from April 27, 2025 to May 10, 2025', [0, 14, 29]],
47+
] as const;
48+
const indicatorOnView = 'The current time indicator is visible in the view';
49+
const indicatorNotOnView = 'The current time indicator is not visible on the screen';
50+
51+
options.forEach(([currentView, title, counts]) => {
52+
counts.forEach((appointmentsCount, index) => {
53+
const schedulerConfig = {
54+
timeZone: 'America/Los_Angeles',
55+
dataSource: appointments.slice(0, 2 * index),
56+
views: [
57+
'agenda', 'day', 'month', 'timelineDay', 'timelineMonth', 'timelineWeek', 'timelineWorkWeek', 'week', 'workWeek', {
58+
name: 'Two Weeks',
59+
type: 'week',
60+
intervalCount: 2,
61+
},
62+
],
63+
currentView,
64+
indicatorTime: today,
65+
currentDate: today,
66+
};
67+
// TODO(2): use `appointmentsCount` here
68+
const generalStatus = `Scheduler. ${title} with ${index === 0 ? 0 : '\\d*'} appointments`;
69+
70+
test(`Scheduler should have correct status message [view=${currentView}, count=${appointmentsCount}, indicator=false]`, async (t) => {
71+
const scheduler = new Scheduler('#container');
72+
73+
await statusCheckEql(t, scheduler, generalStatus);
74+
}).before(async () => {
75+
await createWidget('dxScheduler', { ...schedulerConfig, showCurrentTimeIndicator: false });
76+
});
77+
78+
test(`Scheduler should have correct status message [view=${currentView}, count=${appointmentsCount}, indicator=true]`, async (t) => {
79+
const scheduler = new Scheduler('#container');
80+
81+
await t.click(scheduler.toolbar.navigator.nextButton);
82+
await statusCheck(t, scheduler, currentView === 'month' ? indicatorOnView : indicatorNotOnView);
83+
84+
await t.click(scheduler.toolbar.navigator.prevButton);
85+
await statusCheckEql(t, scheduler, `${generalStatus}. ${indicatorOnView}`);
86+
87+
await t.click(scheduler.toolbar.navigator.prevButton);
88+
await statusCheck(t, scheduler, indicatorNotOnView);
89+
}).before(async () => {
90+
await createWidget('dxScheduler', { ...schedulerConfig, showCurrentTimeIndicator: true });
91+
});
92+
});
93+
});
94+
95+
[
96+
['timelineWeek', 'Scheduler. Timeline Week view: from April 27, 2025 to May 3, 2025 with 5 appointments'],
97+
['week', 'Scheduler. Week view: from April 27, 2025 to May 3, 2025 with 5 appointments'],
98+
].forEach(([currentView, title]) => {
99+
test(`Scheduler should have correct status message if the appointments are partial [view=${currentView}]`, async (t) => {
100+
const scheduler = new Scheduler('#container');
101+
102+
await statusCheckEql(t, scheduler, title);
103+
}).before(async () => {
104+
await createWidget('dxScheduler', {
105+
timeZone: 'America/Los_Angeles',
106+
dataSource: [{
107+
startDate: '2025-04-29T23:18:00.000Z',
108+
endDate: '2025-04-30T16:12:00.000Z',
109+
}, {
110+
startDate: '2025-04-26T23:18:00.000Z',
111+
endDate: '2025-04-27T12:12:00.000Z',
112+
recurrenceRule: 'FREQ=DAILY;INTERVAL=2;COUNT=5',
113+
}],
114+
views: ['timelineWeek', 'week'],
115+
currentView,
116+
indicatorTime: today,
117+
currentDate: today,
118+
});
119+
});
120+
});

packages/devextreme-scss/scss/widgets/base/scheduler/_common.scss

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,15 @@ $scheduler-appointment-collector-margin: 3px;
77
$scheduler-appointment-collector-height: 20px;
88
$scheduler-popup-scrollable-content-padding: 20px;
99

10+
// NOTE: a11y aria-live container must be visible to allow screen readers read it
11+
.dx-scheduler-a11y-status-container {
12+
position: fixed;
13+
left: 0;
14+
top: 0;
15+
clip: rect(1px, 1px, 1px, 1px);
16+
clip-path: polygon(0 0);
17+
}
18+
1019
.dx-scheduler-appointment-popup {
1120
.dx-popup-content {
1221
padding-top: 0;

packages/devextreme/js/__internal/core/utils/m_type.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ const isDate = function (object) {
2828
return type(object) === 'date';
2929
};
3030

31-
const isDefined = function (object) {
31+
const isDefined = function <T>(object: T | null | undefined): object is T {
3232
return (object !== null) && (object !== undefined);
3333
};
3434

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
import type { dxElementWrapper } from '@js/core/renderer';
2+
import $ from '@js/core/renderer';
3+
4+
const CLASSES = {
5+
container: 'dx-scheduler-a11y-status-container',
6+
};
7+
8+
export const createA11yStatusContainer = (statusText = ''): dxElementWrapper => $('<div>')
9+
.text(statusText)
10+
.addClass(CLASSES.container)
11+
.attr('role', 'status');
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
import { describe, expect, it } from '@jest/globals';
2+
3+
import { getA11yStatusText } from './a11y_status_text';
4+
5+
describe('getA11yStatusText', () => {
6+
it('should return text custom view', () => {
7+
expect(getA11yStatusText(
8+
{
9+
name: 'Two Weeks',
10+
type: 'week',
11+
intervalCount: 2,
12+
},
13+
new Date(2021, 10, 17, 1),
14+
new Date(2021, 10, 27, 12),
15+
20,
16+
)).toEqual('Scheduler. Two Weeks view: from November 17, 2021 to November 27, 2021 with 20 appointments');
17+
});
18+
19+
it('should return text month view', () => {
20+
expect(getA11yStatusText(
21+
'month',
22+
new Date(2021, 9, 27, 1),
23+
new Date(2021, 11, 3, 12),
24+
20,
25+
)).toEqual('Scheduler. Month view: from October 2021 to December 2021 with 20 appointments');
26+
});
27+
28+
it('should return text week view', () => {
29+
expect(getA11yStatusText(
30+
'week',
31+
new Date(2021, 10, 21, 1),
32+
new Date(2021, 10, 27, 12),
33+
20,
34+
)).toEqual('Scheduler. Week view: from November 21, 2021 to November 27, 2021 with 20 appointments');
35+
});
36+
37+
it('should return text day view', () => {
38+
expect(getA11yStatusText(
39+
'day',
40+
new Date(2021, 10, 24, 1),
41+
new Date(2021, 10, 24, 12),
42+
20,
43+
)).toEqual('Scheduler. Day view: November 24, 2021 with 20 appointments');
44+
});
45+
46+
it('should return text with indicator on the view', () => {
47+
expect(getA11yStatusText(
48+
'day',
49+
new Date(2021, 10, 24, 1),
50+
new Date(2021, 10, 24, 12),
51+
20,
52+
new Date(2021, 10, 24, 10),
53+
)).toEqual('Scheduler. Day view: November 24, 2021 with 20 appointments. The current time indicator is visible in the view');
54+
});
55+
56+
it('should return text with indicator out of the view', () => {
57+
expect(getA11yStatusText(
58+
'day',
59+
new Date(2021, 10, 24, 1),
60+
new Date(2021, 10, 24, 12),
61+
20,
62+
new Date(2021, 10, 12, 10),
63+
)).toEqual('Scheduler. Day view: November 24, 2021 with 20 appointments. The current time indicator is not visible on the screen');
64+
});
65+
});
Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
import dateLocalization from '@js/common/core/localization/date';
2+
import messageLocalization from '@js/common/core/localization/message';
3+
import { isObject } from '@js/core/utils/type';
4+
import type { ViewType } from '@js/ui/scheduler';
5+
6+
import type { RawViewType } from '../header/types';
7+
8+
const KEYS = {
9+
dateRange: 'dxScheduler-dateRange',
10+
label: 'dxScheduler-ariaLabel',
11+
indicatorPresent: 'dxScheduler-ariaLabel-currentIndicator-present',
12+
indicatorNotPresent: 'dxScheduler-ariaLabel-currentIndicator-not-present',
13+
};
14+
const viewTypeLocalization: Record<ViewType, string> = {
15+
agenda: 'dxScheduler-switcherAgenda',
16+
day: 'dxScheduler-switcherDay',
17+
month: 'dxScheduler-switcherMonth',
18+
week: 'dxScheduler-switcherWeek',
19+
workWeek: 'dxScheduler-switcherWorkWeek',
20+
timelineDay: 'dxScheduler-switcherTimelineDay',
21+
timelineMonth: 'dxScheduler-switcherTimelineMonth',
22+
timelineWeek: 'dxScheduler-switcherTimelineWeek',
23+
timelineWorkWeek: 'dxScheduler-switcherTimelineWorkWeek',
24+
};
25+
26+
const localizeMonth = (date: Date): string => String(dateLocalization.format(date, 'monthAndYear'));
27+
const localizeDate = (date: Date): string => `${dateLocalization.format(date, 'monthAndDay')}, ${dateLocalization.format(date, 'year')}`;
28+
const localizeCurrentIndicator = (
29+
date: Date,
30+
startDate: Date,
31+
endDate: Date,
32+
): string => messageLocalization.format(
33+
date >= startDate && date < endDate
34+
? KEYS.indicatorPresent
35+
: KEYS.indicatorNotPresent,
36+
);
37+
const localizeName = (viewName?: string, viewType?: string): string => {
38+
if (viewName) {
39+
return viewName;
40+
}
41+
if (viewType) {
42+
return messageLocalization.format(viewTypeLocalization[viewType]);
43+
}
44+
45+
return '';
46+
};
47+
48+
export const getA11yStatusText = (
49+
view: RawViewType,
50+
startDate: Date,
51+
endDate: Date,
52+
appointmentCount: number,
53+
indicatorTime?: Date,
54+
): string => {
55+
const viewType = isObject(view) ? view.type : view;
56+
const viewName = isObject(view) ? view.name : undefined;
57+
const viewTypeLabel = localizeName(viewName, viewType);
58+
59+
const isMonth = viewType === 'month' || viewType === 'timelineMonth';
60+
const startDateText = isMonth ? localizeMonth(startDate) : localizeDate(startDate);
61+
const endDateText = isMonth ? localizeMonth(endDate) : localizeDate(endDate);
62+
const intervalText = startDateText === endDateText
63+
? `${startDateText}`
64+
// @ts-expect-error ts-error
65+
: messageLocalization.format(KEYS.dateRange, startDateText, endDateText);
66+
67+
const statusText = messageLocalization
68+
// @ts-expect-error
69+
.format(KEYS.label, viewTypeLabel, intervalText, appointmentCount);
70+
71+
if (indicatorTime) {
72+
const indicatorStatus = localizeCurrentIndicator(indicatorTime, startDate, endDate);
73+
74+
return `${statusText}. ${indicatorStatus}`;
75+
}
76+
77+
return statusText;
78+
};

packages/devextreme/js/__internal/scheduler/appointments/m_appointment_collection.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ import { getAppointmentTakesSeveralDays, sortAppointmentsByStartDate } from './d
3636
import { AgendaAppointment, Appointment } from './m_appointment';
3737
import { createAgendaAppointmentLayout, createAppointmentLayout } from './m_appointment_layout';
3838
import { getAppointmentDateRange } from './resizing/m_core';
39+
import { countVisibleAppointments } from './utils/countVisibleAppointments';
3940

4041
const COMPONENT_CLASS = 'dx-scheduler-scrollable-appointments';
4142

@@ -70,6 +71,10 @@ class SchedulerAppointments extends CollectionWidget {
7071
return this.option('getAppointmentDataProvider')();
7172
}
7273

74+
get appointmentsCount(): number {
75+
return countVisibleAppointments(this.option('items') ?? []);
76+
}
77+
7378
constructor(element, options) {
7479
super(element, options);
7580
this._virtualAppointments = {};

packages/devextreme/js/__internal/scheduler/appointments/m_text_utils.ts

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,6 @@ export const getFormatType = (startDate, endDate, isAllDay, isDateAndTimeView?)
2424
return 'DATETIME';
2525
};
2626

27-
// @ts-expect-error
2827
export const formatDates = (startDate, endDate, formatType) => {
2928
const dateFormat = 'monthandday';
3029
const timeFormat = 'shorttime';
@@ -45,6 +44,6 @@ export const formatDates = (startDate, endDate, formatType) => {
4544
case 'DATE':
4645
return `${dateLocalization.format(startDate, dateFormat)}${isSameDate ? '' : ` - ${dateLocalization.format(endDate, dateFormat)}`}`;
4746
default:
48-
break;
47+
return undefined;
4948
}
5049
};

0 commit comments

Comments
 (0)