fix: accessibility issues on outline and unit pages (#1580)

This PR fixes the following accessibility issues:

1. Header used for screenreader only text
2. Element focus when expanding and dismissing welcome message
3. Bookmark button using wrong ARIA attributing while processing bookmark status
This commit is contained in:
Kristin Aoki
2025-01-31 14:18:28 -05:00
committed by GitHub
parent bd9c97c269
commit 9dc45e192d
11 changed files with 81 additions and 73 deletions

View File

@@ -1,9 +1,9 @@
import React, { useEffect, useState } from 'react'; import { useEffect, useRef, useState } from 'react';
import { useLocation, useNavigate } from 'react-router-dom'; import { useLocation, useNavigate } from 'react-router-dom';
import { useSelector } from 'react-redux'; import { useSelector } from 'react-redux';
import { sendTrackEvent } from '@edx/frontend-platform/analytics'; import { sendTrackEvent } from '@edx/frontend-platform/analytics';
import { getAuthenticatedUser } from '@edx/frontend-platform/auth'; import { getAuthenticatedUser } from '@edx/frontend-platform/auth';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n'; import { useIntl } from '@edx/frontend-platform/i18n';
import { Button } from '@openedx/paragon'; import { Button } from '@openedx/paragon';
import { PluginSlot } from '@openedx/frontend-plugin-framework'; import { PluginSlot } from '@openedx/frontend-plugin-framework';
import { AlertList } from '../../generic/user-messages'; import { AlertList } from '../../generic/user-messages';
@@ -29,7 +29,8 @@ import WelcomeMessage from './widgets/WelcomeMessage';
import ProctoringInfoPanel from './widgets/ProctoringInfoPanel'; import ProctoringInfoPanel from './widgets/ProctoringInfoPanel';
import AccountActivationAlert from '../../alerts/logistration-alert/AccountActivationAlert'; import AccountActivationAlert from '../../alerts/logistration-alert/AccountActivationAlert';
const OutlineTab = ({ intl }) => { const OutlineTab = () => {
const intl = useIntl();
const { const {
courseId, courseId,
proctoringPanelStatus, proctoringPanelStatus,
@@ -42,6 +43,8 @@ const OutlineTab = ({ intl }) => {
userTimezone, userTimezone,
} = useModel('courseHomeMeta', courseId); } = useModel('courseHomeMeta', courseId);
const expandButtonRef = useRef();
const { const {
accessExpiration, accessExpiration,
courseBlocks: { courseBlocks: {
@@ -159,12 +162,12 @@ const OutlineTab = ({ intl }) => {
</> </>
)} )}
<StartOrResumeCourseCard /> <StartOrResumeCourseCard />
<WelcomeMessage courseId={courseId} /> <WelcomeMessage courseId={courseId} nextElementRef={expandButtonRef} />
{rootCourseId && ( {rootCourseId && (
<> <>
<div className="row w-100 m-0 mb-3 justify-content-end"> <div className="row w-100 m-0 mb-3 justify-content-end">
<div className="col-12 col-md-auto p-0"> <div className="col-12 col-md-auto p-0">
<Button variant="outline-primary" block onClick={() => { setExpandAll(!expandAll); }}> <Button ref={expandButtonRef} variant="outline-primary" block onClick={() => { setExpandAll(!expandAll); }}>
{expandAll ? intl.formatMessage(messages.collapseAll) : intl.formatMessage(messages.expandAll)} {expandAll ? intl.formatMessage(messages.collapseAll) : intl.formatMessage(messages.expandAll)}
</Button> </Button>
</div> </div>
@@ -225,8 +228,4 @@ const OutlineTab = ({ intl }) => {
); );
}; };
OutlineTab.propTypes = { export default OutlineTab;
intl: intlShape.isRequired,
};
export default injectIntl(OutlineTab);

View File

@@ -292,6 +292,18 @@ describe('Outline Tab', () => {
showMoreButton = screen.getByRole('button', { name: 'Show More' }); showMoreButton = screen.getByRole('button', { name: 'Show More' });
expect(showMoreButton).toBeInTheDocument(); expect(showMoreButton).toBeInTheDocument();
}); });
fit('dismisses message', async () => {
expect(screen.getByTestId('alert-container-welcome')).toBeInTheDocument();
const dismissButton = screen.queryByRole('button', { name: 'Dismiss' });
const expandButton = screen.queryByRole('button', { name: 'Expand all' });
fireEvent.click(dismissButton);
expect(expandButton).toHaveFocus();
expect(screen.queryByText('Welcome Message')).toBeNull();
});
}); });
it('ignores comments and misformatted HTML', async () => { it('ignores comments and misformatted HTML', async () => {

View File

@@ -1,7 +1,7 @@
import React, { useState, useMemo } from 'react'; import { useState, useMemo, useRef } from 'react';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n'; import { useIntl } from '@edx/frontend-platform/i18n';
import { Alert, Button, TransitionReplace } from '@openedx/paragon'; import { Alert, Button, TransitionReplace } from '@openedx/paragon';
import truncate from 'truncate-html'; import truncate from 'truncate-html';
@@ -11,11 +11,13 @@ import messages from '../messages';
import { useModel } from '../../../generic/model-store'; import { useModel } from '../../../generic/model-store';
import { dismissWelcomeMessage } from '../../data/thunks'; import { dismissWelcomeMessage } from '../../data/thunks';
const WelcomeMessage = ({ courseId, intl }) => { const WelcomeMessage = ({ courseId, nextElementRef }) => {
const intl = useIntl();
const { const {
welcomeMessageHtml, welcomeMessageHtml,
} = useModel('outline', courseId); } = useModel('outline', courseId);
const messageBodyRef = useRef();
const [display, setDisplay] = useState(true); const [display, setDisplay] = useState(true);
// welcomeMessageHtml can contain comments or malformatted HTML which can impact the length that determines // welcomeMessageHtml can contain comments or malformatted HTML which can impact the length that determines
@@ -49,13 +51,20 @@ const WelcomeMessage = ({ courseId, intl }) => {
dismissible dismissible
show={display} show={display}
onClose={() => { onClose={() => {
nextElementRef.current?.focus();
setDisplay(false); setDisplay(false);
dispatch(dismissWelcomeMessage(courseId)); dispatch(dismissWelcomeMessage(courseId));
}} }}
className="raised-card" className="raised-card"
actions={messageCanBeShortened ? [ actions={messageCanBeShortened ? [
<Button <Button
onClick={() => setShowShortMessage(!showShortMessage)} onClick={() => {
if (showShortMessage) {
messageBodyRef.current?.focus();
}
setShowShortMessage(!showShortMessage);
}}
variant="outline-primary" variant="outline-primary"
> >
{showShortMessage ? intl.formatMessage(messages.welcomeMessageShowMoreButton) {showShortMessage ? intl.formatMessage(messages.welcomeMessageShowMoreButton)
@@ -63,32 +72,34 @@ const WelcomeMessage = ({ courseId, intl }) => {
</Button>, </Button>,
] : []} ] : []}
> >
<TransitionReplace className="mb-3" enterDuration={400} exitDuration={200}> <div ref={messageBodyRef} tabIndex="-1">
{showShortMessage ? ( <TransitionReplace className="mb-3" enterDuration={400} exitDuration={200}>
<LmsHtmlFragment {showShortMessage ? (
className="inline-link" <LmsHtmlFragment
data-testid="short-welcome-message-iframe" className="inline-link"
key="short-html" data-testid="short-welcome-message-iframe"
html={shortWelcomeMessageHtml} key="short-html"
title={intl.formatMessage(messages.welcomeMessage)} html={shortWelcomeMessageHtml}
/> title={intl.formatMessage(messages.welcomeMessage)}
) : ( />
<LmsHtmlFragment ) : (
className="inline-link" <LmsHtmlFragment
data-testid="long-welcome-message-iframe" className="inline-link"
key="full-html" data-testid="long-welcome-message-iframe"
html={cleanedWelcomeMessageHtml} key="full-html"
title={intl.formatMessage(messages.welcomeMessage)} html={cleanedWelcomeMessageHtml}
/> title={intl.formatMessage(messages.welcomeMessage)}
)} />
</TransitionReplace> )}
</TransitionReplace>
</div>
</Alert> </Alert>
); );
}; };
WelcomeMessage.propTypes = { WelcomeMessage.propTypes = {
courseId: PropTypes.string.isRequired, courseId: PropTypes.string.isRequired,
intl: intlShape.isRequired, nextElementRef: PropTypes.shape({ current: PropTypes.instanceOf(HTMLInputElement) }),
}; };
export default injectIntl(WelcomeMessage); export default WelcomeMessage;

View File

@@ -1,10 +1,9 @@
import React, { useCallback } from 'react'; import { useCallback } from 'react';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import { StatefulButton } from '@openedx/paragon'; import { Icon, StatefulButton } from '@openedx/paragon';
import { FormattedMessage } from '@edx/frontend-platform/i18n'; import { FormattedMessage } from '@edx/frontend-platform/i18n';
import { useDispatch } from 'react-redux'; import { useDispatch } from 'react-redux';
import BookmarkOutlineIcon from './BookmarkOutlineIcon'; import { Bookmark, BookmarkBorder } from '@openedx/paragon/icons';
import BookmarkFilledIcon from './BookmarkFilledIcon';
import { removeBookmark, addBookmark } from './data/thunks'; import { removeBookmark, addBookmark } from './data/thunks';
const addBookmarkLabel = ( const addBookmarkLabel = (
@@ -42,10 +41,11 @@ const BookmarkButton = ({
return ( return (
<StatefulButton <StatefulButton
variant="link" variant="link"
className="px-1 ml-n1 btn-sm text-primary-500" className={`px-1 ml-n1 btn-sm text-primary-500 ${isProcessing && 'disabled'}`}
onClick={toggleBookmark} onClick={toggleBookmark}
state={state} state={state}
disabledStates={['defaultProcessing', 'bookmarkedProcessing']} aria-busy={isProcessing}
disabled={isProcessing}
labels={{ labels={{
default: addBookmarkLabel, default: addBookmarkLabel,
defaultProcessing: addBookmarkLabel, defaultProcessing: addBookmarkLabel,
@@ -53,10 +53,10 @@ const BookmarkButton = ({
bookmarkedProcessing: hasBookmarkLabel, bookmarkedProcessing: hasBookmarkLabel,
}} }}
icons={{ icons={{
default: <BookmarkOutlineIcon className="text-primary" />, default: <Icon src={BookmarkBorder} className="text-primary" />,
defaultProcessing: <BookmarkOutlineIcon className="text-primary" />, defaultProcessing: <Icon src={BookmarkBorder} className="text-primary" />,
bookmarked: <BookmarkFilledIcon className="text-primary" />, bookmarked: <Icon src={Bookmark} className="text-primary" />,
bookmarkedProcessing: <BookmarkFilledIcon className="text-primary" />, bookmarkedProcessing: <Icon src={Bookmark} className="text-primary" />,
}} }}
/> />
); );

View File

@@ -1,7 +0,0 @@
import React from 'react';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faBookmark } from '@fortawesome/free-solid-svg-icons';
const BookmarkFilledIcon = (props) => <FontAwesomeIcon icon={faBookmark} {...props} />;
export default BookmarkFilledIcon;

View File

@@ -1,7 +0,0 @@
import React from 'react';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faBookmark } from '@fortawesome/free-regular-svg-icons';
const BookmarkOutlineIcon = (props) => <FontAwesomeIcon icon={faBookmark} {...props} />;
export default BookmarkOutlineIcon;

View File

@@ -1,3 +1 @@
export { default as BookmarkButton } from './BookmarkButton'; export { default as BookmarkButton } from './BookmarkButton';
export { default as BookmarkFilledIcon } from './BookmarkFilledIcon';
export { default as BookmarkOutlineIcon } from './BookmarkFilledIcon';

View File

@@ -34,11 +34,11 @@ exports[`Unit component output snapshot: not bookmarked, do not show content 1`]
unitTitle="unit-title" unitTitle="unit-title"
/> />
</div> </div>
<h2 <p
className="sr-only" className="sr-only"
> >
Level 2 headings may be created by course providers in the future. Level 2 headings may be created by course providers in the future.
</h2> </p>
<BookmarkButton <BookmarkButton
isBookmarked={false} isBookmarked={false}
isProcessing={false} isProcessing={false}

View File

@@ -52,7 +52,7 @@ const Unit = ({
<h3 className="h3">{unit.title}</h3> <h3 className="h3">{unit.title}</h3>
<UnitTitleSlot courseId={courseId} unitId={id} unitTitle={unit.title} /> <UnitTitleSlot courseId={courseId} unitId={id} unitTitle={unit.title} />
</div> </div>
<h2 className="sr-only">{formatMessage(messages.headerPlaceholder)}</h2> <p className="sr-only">{formatMessage(messages.headerPlaceholder)}</p>
<BookmarkButton <BookmarkButton
unitId={unit.id} unitId={unit.id}
isBookmarked={unit.bookmarked} isBookmarked={unit.bookmarked}

View File

@@ -1,13 +1,13 @@
import React, { useCallback } from 'react'; import { useCallback } from 'react';
import { Link, useLocation } from 'react-router-dom'; import { Link, useLocation } from 'react-router-dom';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import { connect, useSelector } from 'react-redux'; import { connect, useSelector } from 'react-redux';
import classNames from 'classnames'; import classNames from 'classnames';
import { Button } from '@openedx/paragon'; import { Button, Icon } from '@openedx/paragon';
import { Bookmark } from '@openedx/paragon/icons';
import UnitIcon from './UnitIcon'; import UnitIcon from './UnitIcon';
import CompleteIcon from './CompleteIcon'; import CompleteIcon from './CompleteIcon';
import BookmarkFilledIcon from '../../bookmark/BookmarkFilledIcon';
const UnitButton = ({ const UnitButton = ({
onClick, onClick,
@@ -46,7 +46,9 @@ const UnitButton = ({
{showTitle && <span className="unit-title">{title}</span>} {showTitle && <span className="unit-title">{title}</span>}
{showCompletion && complete ? <CompleteIcon size="sm" className="text-success ml-2" /> : null} {showCompletion && complete ? <CompleteIcon size="sm" className="text-success ml-2" /> : null}
{bookmarked ? ( {bookmarked ? (
<BookmarkFilledIcon <Icon
data-testid="bookmark-icon"
src={Bookmark}
className="text-primary small position-absolute" className="text-primary small position-absolute"
style={{ top: '-3px', right: '5px' }} style={{ top: '-3px', right: '5px' }}
/> />

View File

@@ -63,17 +63,17 @@ describe('Unit Button', () => {
}); });
it('does not show bookmark', () => { it('does not show bookmark', () => {
const { container } = render(<UnitButton {...mockData} />); const { queryByTestId } = render(<UnitButton {...mockData} />);
container.querySelectorAll('svg').forEach(icon => { expect(queryByTestId('bookmark-icon')).toBeNull();
expect(icon).not.toHaveClass('fa-bookmark');
});
}); });
it('shows bookmark', () => { it('shows bookmark', () => {
const { container } = render(<UnitButton {...mockData} unitId={bookmarkedUnit.id} />, { wrapWithRouter: true }); const { container } = render(<UnitButton {...mockData} unitId={bookmarkedUnit.id} />, { wrapWithRouter: true });
const buttonIcons = container.querySelectorAll('svg'); const buttonIcons = container.querySelectorAll('svg');
expect(buttonIcons).toHaveLength(3); expect(buttonIcons).toHaveLength(3);
expect(buttonIcons[2]).toHaveClass('fa-bookmark');
const bookmarkIcon = buttonIcons[2].closest('span');
expect(bookmarkIcon.getAttribute('data-testid')).toBe('bookmark-icon');
}); });
it('handles the click', () => { it('handles the click', () => {