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:
@@ -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);
|
|
||||||
|
|||||||
@@ -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 () => {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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" />,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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;
|
|
||||||
@@ -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;
|
|
||||||
@@ -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';
|
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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' }}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -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', () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user