fix(a11y): accessible names for cert buttons/links in Settings (#48890)

This commit is contained in:
Bruce Blaser 2023-01-06 20:39:45 -08:00 committed by GitHub
parent 95aba7810b
commit 2b2360d77f
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
9 changed files with 129 additions and 85 deletions

View File

@ -510,7 +510,8 @@
"step": "Step",
"steps": "Steps",
"steps-for": "Steps for {{blockTitle}}",
"code-example": "{{codeName}} code example"
"code-example": "{{codeName}} code example",
"opens-new-window": "Opens in new window"
},
"flash": {
"honest-first": "To claim a certification, you must first accept our academic honesty policy",

View File

@ -83,6 +83,7 @@ const ShowProjectLinks = (props: ShowProjectLinksProps): JSX.Element => {
<SolutionDisplayWidget
completedChallenge={completedProject}
dataCy={`${projectTitle} solution`}
projectTitle={projectTitle}
displayContext='certification'
showUserCode={showUserCode}
showProjectPreview={showProjectPreview}

View File

@ -57,7 +57,9 @@ describe('<TimeLine />', () => {
it('Render button when only solution is present', () => {
// @ts-expect-error
render(<TimeLine {...propsForOnlySolution} />, store);
const showViewButton = screen.getByRole('link', { name: 'buttons.view' });
const showViewButton = screen.getByRole('link', {
name: 'buttons.view settings.labels.solution-for (aria.opens-new-window)'
});
expect(showViewButton).toHaveAttribute(
'href',
'https://github.com/freeCodeCamp/freeCodeCamp'
@ -84,7 +86,9 @@ describe('<TimeLine />', () => {
// @ts-expect-error
render(<TimeLine {...propsForOnlySolution} />, store);
const viewButtons = screen.getAllByRole('button', { name: 'buttons.view' });
const viewButtons = screen.getAllByRole('button', {
name: 'buttons.view settings.labels.solution-for'
});
viewButtons.forEach(button => {
expect(button).toBeInTheDocument();
});

View File

@ -103,12 +103,15 @@ function TimelineInner({
function renderViewButton(
completedChallenge: CompletedChallenge
): React.ReactNode {
const { id } = completedChallenge;
const projectTitle = idToNameMap.get(id)?.challengeTitle || '';
return (
<SolutionDisplayWidget
completedChallenge={completedChallenge}
projectTitle={projectTitle}
showUserCode={() => viewSolution(completedChallenge)}
showProjectPreview={() => viewProject(completedChallenge)}
displayContext={'timeline'}
displayContext='timeline'
></SolutionDisplayWidget>
);
}

View File

@ -213,9 +213,10 @@ export class CertificationSettings extends Component {
<SolutionDisplayWidget
completedChallenge={completedProject}
dataCy={projectTitle}
projectTitle={projectTitle}
showUserCode={showUserCode}
showProjectPreview={showProjectPreview}
displayContext={'settings'}
displayContext='settings'
></SolutionDisplayWidget>
);
};
@ -285,7 +286,8 @@ export class CertificationSettings extends Component {
data-cy={`btn-for-${certSlug}`}
onClick={createClickHandler(certSlug)}
>
{isCert ? t('buttons.show-cert') : t('buttons.claim-cert')}
{isCert ? t('buttons.show-cert') : t('buttons.claim-cert')}{' '}
<span className='sr-only'>{certName}</span>
</Button>
</td>
</tr>

View File

@ -20,7 +20,7 @@ describe('<certification />', () => {
expect(
screen.getByRole('link', {
name: 'buttons.show-cert'
name: /^buttons.show-cert\s+\S+/
})
).toHaveAttribute(
'href',
@ -33,7 +33,7 @@ describe('<certification />', () => {
renderWithRedux(<CertificationSettings {...defaultTestProps} />);
const allClaimedCerts = screen.getAllByRole('link', {
name: 'buttons.show-cert'
name: /^buttons.show-cert\s+\S+/
});
allClaimedCerts.forEach(cert => {
@ -49,7 +49,7 @@ describe('<certification />', () => {
renderWithRedux(<CertificationSettings {...defaultTestProps} />);
const allClaimedCerts = screen.getAllByRole('link', {
name: 'buttons.show-cert'
name: /^buttons.show-cert\s+\S+/
});
allClaimedCerts.forEach(cert => {
@ -65,7 +65,7 @@ describe('<certification />', () => {
expect(
screen.getByRole('link', {
name: 'buttons.view'
name: 'buttons.view settings.labels.solution-for (aria.opens-new-window)'
})
).toHaveAttribute('href', 'https://github.com/freeCodeCamp/freeCodeCamp');
});

View File

@ -1,17 +1,15 @@
import { faExternalLinkAlt } from '@fortawesome/free-solid-svg-icons';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import {
Button,
DropdownButton,
MenuItem
} from '@freecodecamp/react-bootstrap';
import { Button, Dropdown, MenuItem } from '@freecodecamp/react-bootstrap';
import React from 'react';
import { useTranslation } from 'react-i18next';
import { CompletedChallenge } from '../../redux/prop-types';
import { getSolutionDisplayType } from '../../utils/solution-display-type';
import './solution-display-widget.css';
interface Props {
completedChallenge: CompletedChallenge;
dataCy?: string;
projectTitle: string;
showUserCode: () => void;
showProjectPreview?: () => void;
displayContext: 'timeline' | 'settings' | 'certification';
@ -20,6 +18,7 @@ interface Props {
export function SolutionDisplayWidget({
completedChallenge,
dataCy,
projectTitle,
showUserCode,
showProjectPreview,
displayContext
@ -29,37 +28,48 @@ export function SolutionDisplayWidget({
const viewText = t('buttons.view');
const viewCode = t('buttons.view-code');
const viewProject = t('buttons.view-project');
// We need to add a random number for dropdown button id's since there may be
// two dropdowns for the same project on the page.
const randomIdSuffix = Math.floor(Math.random() * 1_000_000);
const ShowFilesSolutionForCertification = (
<Button block={true} data-cy={dataCy} onClick={showUserCode}>
{t('buttons.view')}
{viewText}{' '}
<span className='sr-only'>
{t('settings.labels.solution-for', { projectTitle })}
</span>
</Button>
);
const ShowProjectAndGithubLinkForCertification = (
<DropdownButton
block={true}
bsStyle='primary'
className='btn-invert'
id={`dropdown-for-${id}`}
title={t('buttons.view')}
>
<MenuItem
bsStyle='primary'
href={solution ?? ''}
rel='noopener noreferrer'
target='_blank'
>
{t('certification.project.solution')}
</MenuItem>
<MenuItem
bsStyle='primary'
href={githubLink}
rel='noopener noreferrer'
target='_blank'
>
{t('certification.project.source')}
</MenuItem>
</DropdownButton>
<Dropdown id={`dropdown-for-${id}-${randomIdSuffix}`}>
<Dropdown.Toggle block={true} bsStyle='primary' className='btn-invert'>
{viewText}{' '}
<span className='sr-only'>
{t('settings.labels.solution-for', { projectTitle })}
</span>
</Dropdown.Toggle>
<Dropdown.Menu>
<MenuItem
bsStyle='primary'
href={solution ?? ''}
rel='noopener noreferrer'
target='_blank'
>
{t('certification.project.solution')}
<span className='sr-only'>({t('aria.opens-new-window')})</span>
<FontAwesomeIcon icon={faExternalLinkAlt} />
</MenuItem>
<MenuItem
bsStyle='primary'
href={githubLink}
rel='noopener noreferrer'
target='_blank'
>
{t('certification.project.source')}
<span className='sr-only'>({t('aria.opens-new-window')})</span>
<FontAwesomeIcon icon={faExternalLinkAlt} />
</MenuItem>
</Dropdown.Menu>
</Dropdown>
);
const ShowProjectLinkForCertification = (
<Button
@ -69,7 +79,12 @@ export function SolutionDisplayWidget({
rel='noopener noreferrer'
target='_blank'
>
{t('buttons.view')}
{viewText}{' '}
<span className='sr-only'>
{t('settings.labels.solution-for', { projectTitle })} (
{t('aria.opens-new-window')})
</span>
<FontAwesomeIcon icon={faExternalLinkAlt} />
</Button>
);
const MissingSolutionComponentForCertification = (
@ -81,57 +96,67 @@ export function SolutionDisplayWidget({
bsStyle='primary'
className='btn-invert'
data-cy={dataCy}
id={`btn-for-${id}`}
onClick={showUserCode}
>
{viewText} <FontAwesomeIcon icon={faExternalLinkAlt} />
{viewText}{' '}
<span className='sr-only'>
{t('settings.labels.solution-for', { projectTitle })}
</span>
</Button>
);
const ShowMultifileProjectSolution = (
<div className='solutions-dropdown'>
<DropdownButton
block={true}
bsStyle='primary'
className='btn-invert'
id={`dropdown-for-${id}`}
title={t('buttons.view')}
>
<MenuItem bsStyle='primary' onClick={showUserCode}>
{viewCode}
</MenuItem>
<MenuItem bsStyle='primary' onClick={showProjectPreview}>
{viewProject}
</MenuItem>
</DropdownButton>
<Dropdown id={`dropdown-for-${id}-${randomIdSuffix}`}>
<Dropdown.Toggle block={true} bsStyle='primary' className='btn-invert'>
{viewText}{' '}
<span className='sr-only'>
{t('settings.labels.solution-for', { projectTitle })}
</span>
</Dropdown.Toggle>
<Dropdown.Menu>
<MenuItem bsStyle='primary' onClick={showUserCode}>
{viewCode}
</MenuItem>
<MenuItem bsStyle='primary' onClick={showProjectPreview}>
{viewProject}
</MenuItem>
</Dropdown.Menu>
</Dropdown>
</div>
);
const ShowProjectAndGithubLinks = (
<div className='solutions-dropdown'>
<DropdownButton
block={true}
bsStyle='primary'
className='btn-invert'
id={`dropdown-for-${id}`}
title={viewText}
>
<MenuItem
bsStyle='primary'
href={solution}
rel='noopener noreferrer'
target='_blank'
>
{t('buttons.frontend')}
</MenuItem>
<MenuItem
bsStyle='primary'
href={githubLink}
rel='noopener noreferrer'
target='_blank'
>
{t('buttons.backend')}
</MenuItem>
</DropdownButton>
<Dropdown id={`dropdown-for-${id}-${randomIdSuffix}`}>
<Dropdown.Toggle block={true} bsStyle='primary' className='btn-invert'>
{viewText}{' '}
<span className='sr-only'>
{t('settings.labels.solution-for', { projectTitle })}
</span>
</Dropdown.Toggle>
<Dropdown.Menu>
<MenuItem
bsStyle='primary'
href={solution}
rel='noopener noreferrer'
target='_blank'
>
{t('buttons.frontend')}{' '}
<span className='sr-only'>({t('aria.opens-new-window')})</span>
<FontAwesomeIcon icon={faExternalLinkAlt} />
</MenuItem>
<MenuItem
bsStyle='primary'
href={githubLink}
rel='noopener noreferrer'
target='_blank'
>
{t('buttons.backend')}{' '}
<span className='sr-only'>({t('aria.opens-new-window')})</span>
<FontAwesomeIcon icon={faExternalLinkAlt} />
</MenuItem>
</Dropdown.Menu>
</Dropdown>
</div>
);
const ShowProjectLink = (
@ -140,11 +165,15 @@ export function SolutionDisplayWidget({
bsStyle='primary'
className='btn-invert'
href={solution}
id={`btn-for-${id}`}
rel='noopener noreferrer'
target='_blank'
>
{viewText} <FontAwesomeIcon icon={faExternalLinkAlt} />
{viewText}{' '}
<span className='sr-only'>
{t('settings.labels.solution-for', { projectTitle })} (
{t('aria.opens-new-window')})
</span>
<FontAwesomeIcon icon={faExternalLinkAlt} />
</Button>
);
const MissingSolutionComponent =

View File

@ -0,0 +1,3 @@
.solutions-dropdown a[role='menuitem'] {
text-decoration: none;
}

View File

@ -130,7 +130,8 @@ const CertChallenge = ({
>
{isCertified && userLoaded
? t('buttons.show-cert')
: t('buttons.go-to-settings')}
: t('buttons.go-to-settings')}{' '}
<span className='sr-only'>{title}</span>
</Button>
)}
</div>