Skip to content

Commit 220d14e

Browse files
feat: improved accessibility of notification tray
1 parent 52692dc commit 220d14e

File tree

12 files changed

+223
-24
lines changed

12 files changed

+223
-24
lines changed

src/courseware/course/Course.jsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -76,7 +76,7 @@ const Course = ({
7676
const SidebarProviderComponent = isNewDiscussionSidebarViewEnabled ? NewSidebarProvider : SidebarProvider;
7777

7878
return (
79-
<SidebarProviderComponent courseId={courseId} unitId={unitId}>
79+
<SidebarProviderComponent courseId={courseId} unitId={unitId} sectionId={section ? section.id : null}>
8080
<Helmet>
8181
<title>{`${pageTitleBreadCrumbs.join(' | ')} | ${getConfig().SITE_NAME}`}</title>
8282
</Helmet>

src/courseware/course/Course.test.jsx

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,3 @@
1-
import React from 'react';
2-
31
import { Factory } from 'rosie';
42

53
import { breakpoints } from '@openedx/paragon';
@@ -11,6 +9,7 @@ import * as celebrationUtils from './celebration/utils';
119
import { handleNextSectionCelebration } from './celebration';
1210
import Course from './Course';
1311
import setupDiscussionSidebar from './test-utils';
12+
import messages from './messages';
1413

1514
jest.mock('@edx/frontend-platform/analytics');
1615
jest.mock('@edx/frontend-lib-special-exams/dist/data/thunks.js', () => ({
@@ -194,7 +193,9 @@ describe('Course', () => {
194193
it('handles click to open/close notification tray', async () => {
195194
await setupDiscussionSidebar();
196195
waitFor(() => {
197-
const notificationShowButton = screen.findByRole('button', { name: /Show notification tray/i });
196+
const notificationShowButton = screen.findByRole('button', {
197+
name: messages.openNotificationTrigger.defaultMessage,
198+
});
198199
expect(screen.queryByRole('region', { name: /notification tray/i })).not.toBeInTheDocument();
199200
fireEvent.click(notificationShowButton);
200201
expect(screen.queryByRole('region', { name: /notification tray/i })).toBeInTheDocument();

src/courseware/course/JumpNavMenuItem.test.jsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ const mockData = {
1111
sectionId: 'block-v1:edX+DemoX+Demo_Course+type@chapter+block@interactive_demonstrations',
1212
sequenceId: 'block-v1:edX+DemoX+Demo_Course+type@sequential+block@basic_questions',
1313

14+
currentSequence: 'block-v1:edX+DemoX+Demo_Course+type@sequential+block@basic_questions',
1415
title: 'Demo Menu Item',
1516
courseId: 'course-v1:edX+DemoX+Demo_Course',
1617
currentUnit: 'block-v1:edX+DemoX+Demo_Course+type@chapter+block@interactive_demonstrations',

src/courseware/course/messages.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ const messages = defineMessages({
88
},
99
openNotificationTrigger: {
1010
id: 'notification.open.button',
11-
defaultMessage: 'Show notification tray',
11+
defaultMessage: 'Notifications tray',
1212
description: 'Button to open the notification tray and show notifications',
1313
},
1414
closeNotificationTrigger: {

src/courseware/course/sidebar/SidebarContextProvider.jsx

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import { SIDEBARS } from './sidebars';
1515
const SidebarProvider = ({
1616
courseId,
1717
unitId,
18+
sectionId,
1819
children,
1920
}) => {
2021
const { verifiedMode } = useModel('courseHomeMeta', courseId);
@@ -72,8 +73,9 @@ const SidebarProvider = ({
7273
shouldDisplayFullScreen,
7374
courseId,
7475
unitId,
76+
sectionId,
7577
}), [courseId, currentSidebar, notificationStatus, onNotificationSeen, shouldDisplayFullScreen,
76-
shouldDisplaySidebarOpen, toggleSidebar, unitId, upgradeNotificationCurrentState]);
78+
shouldDisplaySidebarOpen, toggleSidebar, unitId, upgradeNotificationCurrentState, sectionId]);
7779

7880
return (
7981
<SidebarContext.Provider value={contextValue}>
@@ -85,6 +87,7 @@ const SidebarProvider = ({
8587
SidebarProvider.propTypes = {
8688
courseId: PropTypes.string.isRequired,
8789
unitId: PropTypes.string.isRequired,
90+
sectionId: PropTypes.string.isRequired,
8891
children: PropTypes.node,
8992
};
9093

src/courseware/course/sidebar/common/SidebarBase.jsx

Lines changed: 121 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,12 @@ import { Icon, IconButton } from '@openedx/paragon';
33
import { ArrowBackIos, Close } from '@openedx/paragon/icons';
44
import classNames from 'classnames';
55
import PropTypes from 'prop-types';
6-
import { useCallback, useContext } from 'react';
6+
import {
7+
useCallback, useContext, useEffect, useRef,
8+
} from 'react';
9+
710
import { useEventListener } from '@src/generic/hooks';
11+
import { setSessionStorage, getSessionStorage } from '@src/data/sessionStorage';
812
import messages from '../../messages';
913
import SidebarContext from '../SidebarContext';
1014

@@ -19,21 +23,127 @@ const SidebarBase = ({
1923
}) => {
2024
const intl = useIntl();
2125
const {
26+
courseId,
2227
toggleSidebar,
2328
shouldDisplayFullScreen,
2429
currentSidebar,
2530
} = useContext(SidebarContext);
2631

32+
const closeBtnRef = useRef(null);
33+
const responsiveCloseNotificationTrayRef = useRef(null);
34+
const isOpenNotificationTray = getSessionStorage(`notificationTrayStatus.${courseId}`) === 'open';
35+
const isFocusedNotificationTray = getSessionStorage(`notificationTrayFocus.${courseId}`) === 'true';
36+
37+
useEffect(() => {
38+
if (isOpenNotificationTray && isFocusedNotificationTray && closeBtnRef.current) {
39+
closeBtnRef.current.focus();
40+
}
41+
42+
if (shouldDisplayFullScreen) {
43+
responsiveCloseNotificationTrayRef.current?.focus();
44+
}
45+
});
46+
2747
const receiveMessage = useCallback(({ data }) => {
2848
const { type } = data;
2949
if (type === 'learning.events.sidebar.close') {
3050
toggleSidebar(null);
3151
}
3252
// eslint-disable-next-line react-hooks/exhaustive-deps
33-
}, [sidebarId, toggleSidebar]);
53+
}, [toggleSidebar]);
3454

3555
useEventListener('message', receiveMessage);
3656

57+
const focusSidebarTriggerBtn = () => {
58+
const performFocus = () => {
59+
const sidebarTriggerBtn = document.querySelector('.sidebar-trigger-btn');
60+
if (sidebarTriggerBtn) {
61+
sidebarTriggerBtn.focus();
62+
}
63+
};
64+
65+
requestAnimationFrame(() => {
66+
requestAnimationFrame(performFocus);
67+
});
68+
};
69+
70+
const handleCloseNotificationTray = () => {
71+
toggleSidebar(null);
72+
setSessionStorage(`notificationTrayFocus.${courseId}`, 'true');
73+
setSessionStorage(`notificationTrayStatus.${courseId}`, 'closed');
74+
focusSidebarTriggerBtn();
75+
};
76+
77+
const handleKeyDown = useCallback((event) => {
78+
const { key, shiftKey, target } = event;
79+
80+
if (key !== 'Tab' || target !== closeBtnRef.current) {
81+
return;
82+
}
83+
84+
// Shift + Tab
85+
if (shiftKey) {
86+
event.preventDefault();
87+
focusSidebarTriggerBtn();
88+
return;
89+
}
90+
91+
// Tab
92+
const courseOutlineTrigger = document.querySelector('#courseOutlineTrigger');
93+
if (courseOutlineTrigger) {
94+
event.preventDefault();
95+
courseOutlineTrigger.focus();
96+
return;
97+
}
98+
99+
const leftArrow = document.querySelector('.previous-button');
100+
if (leftArrow && !leftArrow.disabled) {
101+
event.preventDefault();
102+
leftArrow.focus();
103+
return;
104+
}
105+
106+
const rightArrow = document.querySelector('.next-button');
107+
if (rightArrow && !rightArrow.disabled) {
108+
event.preventDefault();
109+
rightArrow.focus();
110+
}
111+
}, [focusSidebarTriggerBtn, closeBtnRef]);
112+
113+
useEffect(() => {
114+
document.addEventListener('keydown', handleKeyDown);
115+
return () => {
116+
document.removeEventListener('keydown', handleKeyDown);
117+
};
118+
}, [handleKeyDown]);
119+
120+
const handleKeyDownNotificationTray = (event) => {
121+
const { key, shiftKey } = event;
122+
const currentElement = event.target === responsiveCloseNotificationTrayRef.current;
123+
const sidebarTriggerBtn = document.querySelector('.call-to-action-btn');
124+
125+
switch (key) {
126+
case 'Enter':
127+
if (currentElement) {
128+
handleCloseNotificationTray();
129+
}
130+
break;
131+
132+
case 'Tab':
133+
if (!shiftKey && sidebarTriggerBtn) {
134+
event.preventDefault();
135+
sidebarTriggerBtn.focus();
136+
} else if (shiftKey) {
137+
event.preventDefault();
138+
responsiveCloseNotificationTrayRef.current?.focus();
139+
}
140+
break;
141+
142+
default:
143+
break;
144+
}
145+
};
146+
37147
return (
38148
<section
39149
className={classNames('ml-0 border border-light-400 rounded-sm h-auto align-top zindex-0', {
@@ -49,9 +159,10 @@ const SidebarBase = ({
49159
{shouldDisplayFullScreen ? (
50160
<div
51161
className="pt-2 pb-2.5 border-bottom border-light-400 d-flex align-items-center ml-2"
52-
onClick={() => toggleSidebar(null)}
53-
onKeyDown={() => toggleSidebar(null)}
162+
onClick={handleCloseNotificationTray}
163+
onKeyDown={handleKeyDownNotificationTray}
54164
role="button"
165+
ref={responsiveCloseNotificationTrayRef}
55166
tabIndex="0"
56167
>
57168
<Icon src={ArrowBackIos} />
@@ -63,16 +174,20 @@ const SidebarBase = ({
63174
{showTitleBar && (
64175
<>
65176
<div className="d-flex align-items-center mb-2">
66-
<strong className="p-2.5 d-inline-block course-sidebar-title">{title}</strong>
177+
{/* TODO: view this title in UI and decide */}
178+
{/* <strong className="p-2.5 d-inline-block course-sidebar-title">{title}</strong> */}
179+
<h2 className="p-2.5 d-inline-block m-0 text-gray-700 h4">{title}</h2>
67180
{shouldDisplayFullScreen
68181
? null
69182
: (
70183
<div className="d-inline-flex mr-2 ml-auto">
71184
<IconButton
185+
className="sidebar-close-btn"
72186
src={Close}
73187
size="sm"
188+
ref={closeBtnRef}
74189
iconAs={Icon}
75-
onClick={() => toggleSidebar(null)}
190+
onClick={handleCloseNotificationTray}
76191
variant="primary"
77192
alt={intl.formatMessage(messages.closeNotificationTrigger)}
78193
/>
Lines changed: 15 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,27 +1,37 @@
11
import PropTypes from 'prop-types';
2-
import React from 'react';
2+
import { forwardRef } from 'react';
33

4-
const SidebarTriggerBase = ({
4+
const SidebarTriggerBase = forwardRef(({
55
onClick,
6+
onKeyDown,
67
ariaLabel,
78
children,
8-
}) => (
9+
isOpenNotificationStatusBar,
10+
sectionId,
11+
}, ref) => (
912
<button
10-
className="border border-light-400 bg-transparent align-items-center align-content-center d-flex notification-btn"
13+
className="border border-light-400 bg-transparent align-items-center align-content-center d-flex notification-btn sidebar-trigger-btn"
1114
type="button"
1215
onClick={onClick}
16+
onKeyDown={onKeyDown}
1317
aria-label={ariaLabel}
18+
aria-expanded={isOpenNotificationStatusBar}
19+
aria-controls={sectionId}
20+
ref={ref}
1421
>
1522
<div className="icon-container d-flex position-relative align-items-center">
1623
{children}
1724
</div>
1825
</button>
19-
);
26+
));
2027

2128
SidebarTriggerBase.propTypes = {
2229
onClick: PropTypes.func.isRequired,
30+
onKeyDown: PropTypes.func.isRequired,
2331
ariaLabel: PropTypes.string.isRequired,
2432
children: PropTypes.element.isRequired,
33+
isOpenNotificationStatusBar: PropTypes.bool.isRequired,
34+
sectionId: PropTypes.string.isRequired,
2535
};
2636

2737
export default SidebarTriggerBase;

src/courseware/course/sidebar/sidebars/course-outline/CourseOutlineTrigger.jsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ const CourseOutlineTrigger = ({ isMobileView }) => {
3232
>
3333
<IconButton
3434
alt={intl.formatMessage(messages.toggleCourseOutlineTrigger)}
35+
id="courseOutlineTrigger"
3536
className="outline-sidebar-toggle-btn flex-shrink-0 text-dark bg-light-200 rounded-0"
3637
iconAs={MenuOpenIcon}
3738
onClick={handleToggleCollapse}

src/courseware/course/sidebar/sidebars/notifications/NotificationTray.jsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ const NotificationTray = () => {
1919
shouldDisplayFullScreen,
2020
upgradeNotificationCurrentState,
2121
setUpgradeNotificationCurrentState,
22+
currentSidebar,
2223
} = useContext(SidebarContext);
2324
const course = useModel('coursewareMeta', courseId);
2425

@@ -80,6 +81,7 @@ const NotificationTray = () => {
8081
courseId={courseId}
8182
notificationCurrentState={upgradeNotificationCurrentState}
8283
setNotificationCurrentState={setUpgradeNotificationCurrentState}
84+
currentSidebar={currentSidebar}
8385
/>
8486
) : (
8587
<p className="p-3 small">{intl.formatMessage(messages.noNotificationsMessage)}</p>

src/courseware/course/sidebar/sidebars/notifications/NotificationTray.test.jsx

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,8 @@ import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
44
import { breakpoints } from '@openedx/paragon';
55

66
import MockAdapter from 'axios-mock-adapter';
7-
import React from 'react';
87
import { Factory } from 'rosie';
8+
import messages from '../../../messages';
99
import {
1010
fireEvent, initializeMockApp, render, screen, waitFor,
1111
} from '../../../../../setupTest';
@@ -66,7 +66,9 @@ describe('NotificationTray', () => {
6666
);
6767
expect(screen.getByText('Notifications'))
6868
.toBeInTheDocument();
69-
const notificationCloseIconButton = screen.getByRole('button', { name: /Close notification tray/i });
69+
const notificationCloseIconButton = screen.getByRole('button', {
70+
name: messages.closeNotificationTrigger.defaultMessage,
71+
});
7072
expect(notificationCloseIconButton)
7173
.toBeInTheDocument();
7274
expect(notificationCloseIconButton)

0 commit comments

Comments
 (0)