mirror of
https://github.com/freeCodeCamp/freeCodeCamp.git
synced 2026-06-22 21:08:12 +08:00
fix(a11y): accessible names for cert buttons/links in Settings (#48890)
This commit is contained in:
parent
95aba7810b
commit
2b2360d77f
@ -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",
|
||||
|
||||
@ -83,6 +83,7 @@ const ShowProjectLinks = (props: ShowProjectLinksProps): JSX.Element => {
|
||||
<SolutionDisplayWidget
|
||||
completedChallenge={completedProject}
|
||||
dataCy={`${projectTitle} solution`}
|
||||
projectTitle={projectTitle}
|
||||
displayContext='certification'
|
||||
showUserCode={showUserCode}
|
||||
showProjectPreview={showProjectPreview}
|
||||
|
||||
@ -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();
|
||||
});
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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');
|
||||
});
|
||||
|
||||
@ -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 =
|
||||
|
||||
@ -0,0 +1,3 @@
|
||||
.solutions-dropdown a[role='menuitem'] {
|
||||
text-decoration: none;
|
||||
}
|
||||
@ -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>
|
||||
|
||||
Loading…
Reference in New Issue
Block a user