feat: improved accessibility of notification tray#1817
feat: improved accessibility of notification tray#1817filippovskii09 wants to merge 7 commits intoopenedx:masterfrom
Conversation
|
Thanks for the pull request, @filippovskii09! This repository is currently maintained by Once you've gone through the following steps feel free to tag them in a comment and let them know that your changes are ready for engineering review. 🔘 Get product approvalIf you haven't already, check this list to see if your contribution needs to go through the product review process.
🔘 Provide contextTo help your reviewers and other members of the community understand the purpose and larger context of your changes, feel free to add as much of the following information to the PR description as you can:
🔘 Get a green buildIf one or more checks are failing, continue working on your changes until this is no longer the case and your build turns green. 🔘 Update the status of your PRYour PR is currently marked as a draft. After completing the steps above, update its status by clicking "Ready for Review", or removing "WIP" from the title, as appropriate. Where can I find more information?If you'd like to get more details on all aspects of the review process for open source pull requests (OSPRs), check out the following resources: When can I expect my changes to be merged?Our goal is to get community contributions seen and reviewed as efficiently as possible. However, the amount of time that it takes to review and merge a PR can vary significantly based on factors such as:
💡 As a result it may take up to several weeks or months to complete a review and merge your PR. |
Codecov Report❌ Patch coverage is
Additional details and impacted files@@ Coverage Diff @@
## master #1817 +/- ##
==========================================
+ Coverage 90.83% 91.26% +0.43%
==========================================
Files 345 350 +5
Lines 5791 5872 +81
Branches 1380 1393 +13
==========================================
+ Hits 5260 5359 +99
+ Misses 512 494 -18
Partials 19 19 ☔ View full report in Codecov by Sentry. 🚀 New features to boost your workflow:
|
| } | ||
|
|
||
| // Tab | ||
| const courseOutlineTrigger = document.querySelector('#courseOutlineTrigger'); |
There was a problem hiding this comment.
[optional]: Maybe it would be better to clarify what exactly this is a sidebar trigger.
| const courseOutlineTrigger = document.querySelector('#courseOutlineTrigger'); | |
| const courseOutlineSidebarTrigger = document.querySelector('#courseOutlineTrigger'); |
There was a problem hiding this comment.
yes, this naming better, fixed
| const leftArrow = document.querySelector('.previous-button'); | ||
| if (leftArrow && !leftArrow.disabled) { | ||
| event.preventDefault(); | ||
| leftArrow.focus(); | ||
| return; | ||
| } | ||
|
|
||
| const rightArrow = document.querySelector('.next-button'); | ||
| if (rightArrow && !rightArrow.disabled) { | ||
| event.preventDefault(); | ||
| rightArrow.focus(); | ||
| } |
There was a problem hiding this comment.
[question]: These are two almost identical constructs. Can we create a function for this that would be convenient to reuse here?
There was a problem hiding this comment.
thx, create helper function for this, fixed
| focusSidebarTriggerBtn(); | ||
| }; | ||
|
|
||
| const handleKeyDown = useCallback((event) => { |
There was a problem hiding this comment.
[nit]: I think it's worth adding a meaningful comment here that would describe how the logic of moving focus around the page works and for which elements.
There was a problem hiding this comment.
i added describe text for function, please check it
| <div className="d-flex align-items-center mb-2"> | ||
| <strong className="p-2.5 d-inline-block course-sidebar-title">{title}</strong> | ||
| {/* TODO: view this title in UI and decide */} | ||
| {/* <strong className="p-2.5 d-inline-block course-sidebar-title">{title}</strong> */} |
There was a problem hiding this comment.
Do we need these comments?
| const newFocusStatus = !isOpenNotificationStatusBar; | ||
| setSessionStorage(`notificationTrayFocus.${courseId}`, String(newFocusStatus)); | ||
|
|
||
| const isNotificationTrayOpen = getSessionStorage(`notificationTrayStatus.${courseId}`) === 'open'; |
There was a problem hiding this comment.
[nit]: I suggest splitting the logic into two PRs:
- Improve accessibility issues
- Fix bug with storing sidebar state with notifications
There was a problem hiding this comment.
ok, i removed Fixes for storing sidebar state
There was a problem hiding this comment.
i added this custom hook useSidebarFocusAndKeyboard.js to separate the logic which we added in this PR
because a lot of logic interfered with orientation in the component
| import { renderHook, act } from '@testing-library/react'; | ||
| import SidebarContext from '../../SidebarContext'; |
There was a problem hiding this comment.
| import { renderHook, act } from '@testing-library/react'; | |
| import SidebarContext from '../../SidebarContext'; | |
| import { renderHook, act } from '@testing-library/react'; | |
| import SidebarContext from '../../SidebarContext'; |
| @@ -0,0 +1,152 @@ | |||
| import React from 'react'; | |||
There was a problem hiding this comment.
Do we need this React import?
| import { Factory } from 'rosie'; | ||
| import { |
There was a problem hiding this comment.
[code style]: Let’s separate external library imports from local imports with a blank line for better readability.
| import { Factory } from 'rosie'; | |
| import { | |
| import { Factory } from 'rosie'; | |
| import { |
There was a problem hiding this comment.
Should the line import { createRef } from 'react'; come before import { Factory } from 'rosie';? The idea is to separate library imports from local imports.
| import { Factory } from 'rosie'; | ||
| import messages from '../../../messages'; |
There was a problem hiding this comment.
[question]: Does it make sense to use aliases here?
| import { Factory } from 'rosie'; | |
| import messages from '../../../messages'; | |
| import { Factory } from 'rosie'; | |
| import messages from '../../../messages'; |
| import PropTypes from 'prop-types'; | ||
| import { Factory } from 'rosie'; | ||
|
|
||
| import messages from '../../../messages'; |
There was a problem hiding this comment.
[question]: Does it make sense to use aliases here?
| }); | ||
|
|
||
| expect(mockToggleSidebar).toHaveBeenCalledWith(null); | ||
| act(() => { jest.runAllTimers(); }); |
There was a problem hiding this comment.
| act(() => { jest.runAllTimers(); }); | |
| act(() => jest.runAllTimers()); |
|
|
||
| const mockUseSidebarFocusAndKeyboard = useSidebarFocusAndKeyboard; | ||
|
|
||
| describe('SidebarBase (Refactored)', () => { |
There was a problem hiding this comment.
| describe('SidebarBase (Refactored)', () => { | |
| describe('SidebarBase', () => { |
| } | ||
| // eslint-disable-next-line react-hooks/exhaustive-deps | ||
| }, [sidebarId, toggleSidebar]); | ||
| }, [toggleSidebar]); |
There was a problem hiding this comment.
[clarify]: Is there any reason why sidebarId was removed from deps? Also, do I need to remove // eslint-disable-next-line react-hooks/exhaustive-deps?
There was a problem hiding this comment.
My question is related to the fact that sidebarId was previously included in the dependency array. Why was it removed?
There was a problem hiding this comment.
because sidebarId it was an unnecessary dependency
| const triggerButton = screen.getByRole('button', { | ||
| name: messages.openNotificationTrigger.defaultMessage, | ||
| }); | ||
| fireEvent.click(triggerButton); |
There was a problem hiding this comment.
Lets use userEvent
| sectionId: 'block-v1:edX+DemoX+Demo_Course+type@chapter+block@interactive_demonstrations', | ||
| sequenceId: 'block-v1:edX+DemoX+Demo_Course+type@sequential+block@basic_questions', | ||
|
|
||
| currentSequence: 'block-v1:edX+DemoX+Demo_Course+type@sequential+block@basic_questions', |
There was a problem hiding this comment.
Just curious. Why was this change only added now?
47e07d5 to
217a46c
Compare
| import { tryFocusAndPreventDefault } from '../../utils'; | ||
| import SidebarContext from '../../SidebarContext'; | ||
|
|
||
| export const useSidebarFocusAndKeyboard = (sidebarId, triggerButtonSelector = '.sidebar-trigger-btn') => { |
| import { renderHook, act } from '@testing-library/react'; | ||
| import SidebarContext from '../../SidebarContext'; |
| import { Factory } from 'rosie'; | ||
| import { |
There was a problem hiding this comment.
Should the line import { createRef } from 'react'; come before import { Factory } from 'rosie';? The idea is to separate library imports from local imports.
|
|
||
| it('should hide title bar when showTitleBar prop is false', () => { | ||
| renderSidebar({ currentSidebar: SIDEBAR_ID }, { showTitleBar: false }); | ||
| expect(screen.queryByText('Test Sidebar Title')).not.toBeInTheDocument(); |
There was a problem hiding this comment.
Can we replace the static text in these tests with values from defaultProps?
| } | ||
| // eslint-disable-next-line react-hooks/exhaustive-deps | ||
| }, [sidebarId, toggleSidebar]); | ||
| }, [toggleSidebar]); |
There was a problem hiding this comment.
My question is related to the fact that sidebarId was previously included in the dependency array. Why was it removed?
| import { Factory } from 'rosie'; | ||
| import messages from '@src/courseware/course/messages'; |
There was a problem hiding this comment.
| import { Factory } from 'rosie'; | |
| import messages from '@src/courseware/course/messages'; | |
| import { Factory } from 'rosie'; | |
| import messages from '@src/courseware/course/messages'; |
| @@ -0,0 +1,9 @@ | |||
| export const tryFocusAndPreventDefault = (event, selector) => { | |||
|
Sandbox deployment successful 🚀 |
|
Sandbox deployment successful 🚀 |
|
Hey @PKulkoRaccoonGang, am I right in assuming that this is another PR you'll add when you create your product proposal for a11y improvements? |
|
|
||
| mockQuerySelector = jest.spyOn(document, 'querySelector'); | ||
| mockQuerySelector.mockImplementation((selector) => { | ||
| if (selector === TRIGGER_SELECTOR) { return triggerButtonMock; } |
There was a problem hiding this comment.
| if (selector === TRIGGER_SELECTOR) { return triggerButtonMock; } | |
| if (selector === TRIGGER_SELECTOR) { | |
| return triggerButtonMock; | |
| } |
| if (selector === '#courseOutlineSidebarTrigger') { return mockOutlineTrigger; } | ||
| if (selector === '.previous-button') { return mockPrevButton; } | ||
| if (selector === '.next-button') { return mockNextButton; } |
There was a problem hiding this comment.
| if (selector === '#courseOutlineSidebarTrigger') { return mockOutlineTrigger; } | |
| if (selector === '.previous-button') { return mockPrevButton; } | |
| if (selector === '.next-button') { return mockNextButton; } | |
| if (selector === '#courseOutlineSidebarTrigger') { | |
| return mockOutlineTrigger; | |
| } | |
| if (selector === '.previous-button') { | |
| return mockPrevButton; | |
| } | |
| if (selector === '.next-button') { | |
| return mockNextButton; | |
| } |
| const { result } = renderHookWithContext(mockContextValue); | ||
|
|
||
| mockQuerySelector.mockImplementation((selector) => { | ||
| if (selector === TRIGGER_SELECTOR) { return triggerButtonMock; } |
There was a problem hiding this comment.
| if (selector === TRIGGER_SELECTOR) { return triggerButtonMock; } | |
| if (selector === TRIGGER_SELECTOR) { | |
| return triggerButtonMock; | |
| } |
| if (selector === '.call-to-action-btn') { return mockCtaButton; } | ||
| if (selector === TRIGGER_SELECTOR) { return triggerButtonMock; } |
There was a problem hiding this comment.
| if (selector === '.call-to-action-btn') { return mockCtaButton; } | |
| if (selector === TRIGGER_SELECTOR) { return triggerButtonMock; } | |
| if (selector === '.call-to-action-btn') { | |
| return mockCtaButton; | |
| } | |
| if (selector === TRIGGER_SELECTOR) { | |
| return triggerButtonMock; | |
| } |
| sidebarTriggerBtnRef.current?.blur(); | ||
| sidebarTriggerBtnRef.current?.blur(); | ||
|
|
||
| sidebarTriggerBtnRef.current?.blur(); |
There was a problem hiding this comment.
[clarify]: Do we need to add blur to sidebarTriggerBtnRef three times here?
|
Sandbox deployment failed 💥 |
|
Sandbox deployment failed 💥 |
|
Sandbox deployment successful 🚀 |
|
Sandbox deployment failed 💥 |
|
Sandbox deployment failed 💥 |
|
Sandbox deployment successful 🚀 |
Note
This PR has been added to the product proposal to improve accessibility.
Product proposal
Description
This PR introduces significant accessibility (a11y) improvements to the Notification Tray (Sidebar) and its Trigger Button, making the feature fully operable for keyboard-only users.
Key Changes
Trigger State
aria-expandedattribute to announce its state (open/closed) to assistive technologies.Focus Management
Managed Keyboard Navigation
Tabkey behavior when the sidebar is open.Steps to Test
Note: These steps should be performed using the keyboard to verify accessibility.
Tabkey repeatedly to navigate through the page's interactive elements.Enterto activate the trigger.Enteragain to close the tray.Before:
2025-10-31.16.00.26.mov
After:
2025-10-30.13.47.42.mov