mirror of
https://github.com/freeCodeCamp/freeCodeCamp.git
synced 2026-05-28 10:22:16 +00:00
fix(a11y): update independent lower jaw to announce hint and completion messages (#67464)
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
import React from 'react';
|
||||
import { screen } from '@testing-library/react';
|
||||
import { screen, waitFor } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { useStaticQuery } from 'gatsby';
|
||||
@@ -39,6 +39,7 @@ const baseProps = {
|
||||
submitChallenge: vi.fn(),
|
||||
askSocrates: vi.fn(),
|
||||
saveChallenge: vi.fn(),
|
||||
attempts: 0,
|
||||
tests: passingTests,
|
||||
isSignedIn: true,
|
||||
challengeMeta: baseChallengeMeta,
|
||||
@@ -57,6 +58,13 @@ const baseProps = {
|
||||
|
||||
vi.mock('../../../utils/get-words');
|
||||
|
||||
const getLiveRegion = () => {
|
||||
const region = screen.getByTestId('independent-lower-jaw-live-region');
|
||||
expect(region).toHaveAttribute('aria-live', 'polite');
|
||||
expect(region).toHaveAttribute('aria-atomic', 'true');
|
||||
return region;
|
||||
};
|
||||
|
||||
describe('<IndependentLowerJaw />', () => {
|
||||
beforeEach(() => {
|
||||
showSocratesFlag = true;
|
||||
@@ -157,4 +165,134 @@ describe('<IndependentLowerJaw />', () => {
|
||||
expect(screen.getByText(/2\/3/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/learn\.hints-used-today/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('announces hint text through a live region', async () => {
|
||||
const failingTests: Test[] = [
|
||||
{
|
||||
pass: false,
|
||||
err: 'Use <code><main></code> here.',
|
||||
message: 'Use <code><main></code> here.',
|
||||
text: 'test',
|
||||
testString: 'test'
|
||||
}
|
||||
];
|
||||
|
||||
render(
|
||||
<IndependentLowerJaw {...baseProps} tests={failingTests} />,
|
||||
createStore()
|
||||
);
|
||||
|
||||
expect(getLiveRegion()).toHaveTextContent('');
|
||||
|
||||
await waitFor(() =>
|
||||
expect(getLiveRegion()).toHaveTextContent('Use <main> here.')
|
||||
);
|
||||
});
|
||||
|
||||
it('re-announces the same hint after each check attempt', async () => {
|
||||
const firstFailingTests: Test[] = [
|
||||
{
|
||||
pass: false,
|
||||
err: 'Use <code><main></code> here.',
|
||||
message: 'Use <code><main></code> here.',
|
||||
text: 'test',
|
||||
testString: 'test'
|
||||
}
|
||||
];
|
||||
const thirdFailingTests: Test[] = [
|
||||
{
|
||||
pass: false,
|
||||
err: 'Use <code><main></code> here.',
|
||||
message: 'Use <code><main></code> here.',
|
||||
text: 'test',
|
||||
testString: 'test'
|
||||
}
|
||||
];
|
||||
const secondFailingTests: Test[] = [
|
||||
{
|
||||
pass: false,
|
||||
err: 'Use <code><main></code> here.',
|
||||
message: 'Use <code><main></code> here.',
|
||||
text: 'test',
|
||||
testString: 'test'
|
||||
}
|
||||
];
|
||||
|
||||
const { rerender } = render(
|
||||
<IndependentLowerJaw {...baseProps} tests={firstFailingTests} />,
|
||||
createStore()
|
||||
);
|
||||
|
||||
expect(getLiveRegion()).toHaveTextContent('');
|
||||
|
||||
await waitFor(() =>
|
||||
expect(getLiveRegion()).toHaveTextContent('Use <main> here.')
|
||||
);
|
||||
|
||||
rerender(
|
||||
<IndependentLowerJaw
|
||||
{...baseProps}
|
||||
attempts={1}
|
||||
tests={secondFailingTests}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(getLiveRegion()).toHaveTextContent('');
|
||||
|
||||
await waitFor(() =>
|
||||
expect(getLiveRegion()).toHaveTextContent('Use <main> here.')
|
||||
);
|
||||
|
||||
rerender(
|
||||
<IndependentLowerJaw
|
||||
{...baseProps}
|
||||
attempts={2}
|
||||
tests={thirdFailingTests}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(getLiveRegion()).toHaveTextContent('');
|
||||
|
||||
await waitFor(() =>
|
||||
expect(getLiveRegion()).toHaveTextContent('Use <main> here.')
|
||||
);
|
||||
});
|
||||
|
||||
it('announces completion text through a hidden live region', async () => {
|
||||
render(<IndependentLowerJaw {...baseProps} />, createStore());
|
||||
|
||||
expect(getLiveRegion()).toHaveTextContent('');
|
||||
|
||||
await waitFor(() =>
|
||||
expect(getLiveRegion()).toHaveTextContent(
|
||||
/learn\.congratulations-code-passes .* learn\.percent-complete/
|
||||
)
|
||||
);
|
||||
});
|
||||
|
||||
it('does not reset the completion live region on passing rerenders', async () => {
|
||||
const firstPassingTests: Test[] = [
|
||||
{ pass: true, text: 'test', testString: 'test' }
|
||||
];
|
||||
const secondPassingTests: Test[] = [
|
||||
{ pass: true, text: 'test', testString: 'test' }
|
||||
];
|
||||
|
||||
const { rerender } = render(
|
||||
<IndependentLowerJaw {...baseProps} tests={firstPassingTests} />,
|
||||
createStore()
|
||||
);
|
||||
|
||||
await waitFor(() =>
|
||||
expect(getLiveRegion()).toHaveTextContent(
|
||||
/learn\.congratulations-code-passes .* learn\.percent-complete/
|
||||
)
|
||||
);
|
||||
|
||||
rerender(<IndependentLowerJaw {...baseProps} tests={secondPassingTests} />);
|
||||
|
||||
expect(getLiveRegion()).toHaveTextContent(
|
||||
/learn\.congratulations-code-passes .* learn\.percent-complete/
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React from 'react';
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { connect } from 'react-redux';
|
||||
import { createSelector } from 'reselect';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
} from '../../../redux/selectors';
|
||||
import { ChallengeMeta, Test } from '../../../redux/prop-types';
|
||||
import {
|
||||
attemptsSelector,
|
||||
challengeMetaSelector,
|
||||
challengeTestsSelector,
|
||||
completedPercentageSelector,
|
||||
@@ -47,7 +48,43 @@ type SocratesHintState = {
|
||||
limit: null | number;
|
||||
};
|
||||
|
||||
interface StatusAnnouncementProps {
|
||||
message: string;
|
||||
}
|
||||
|
||||
const StatusAnnouncement = ({
|
||||
message
|
||||
}: StatusAnnouncementProps): JSX.Element => {
|
||||
const [announcement, setAnnouncement] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
setAnnouncement('');
|
||||
|
||||
if (!message) return;
|
||||
|
||||
const announceTimeout = window.setTimeout(() => {
|
||||
setAnnouncement(message);
|
||||
}, 100);
|
||||
|
||||
return () => {
|
||||
window.clearTimeout(announceTimeout);
|
||||
};
|
||||
}, [message]);
|
||||
|
||||
return (
|
||||
<span
|
||||
aria-atomic='true'
|
||||
aria-live='polite'
|
||||
className='sr-only'
|
||||
data-testid='independent-lower-jaw-live-region'
|
||||
>
|
||||
{announcement}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
const mapStateToProps = createSelector(
|
||||
attemptsSelector,
|
||||
challengeTestsSelector,
|
||||
isSignedInSelector,
|
||||
challengeMetaSelector,
|
||||
@@ -57,6 +94,7 @@ const mapStateToProps = createSelector(
|
||||
socratesHintStateSelector,
|
||||
isSocratesOnSelector,
|
||||
(
|
||||
attempts: number,
|
||||
tests: Test[],
|
||||
isSignedIn: boolean,
|
||||
challengeMeta: ChallengeMeta,
|
||||
@@ -66,6 +104,7 @@ const mapStateToProps = createSelector(
|
||||
socratesHintState: SocratesHintState,
|
||||
hasSocratesAccess: boolean
|
||||
) => ({
|
||||
attempts,
|
||||
tests,
|
||||
isSignedIn,
|
||||
challengeMeta,
|
||||
@@ -91,6 +130,7 @@ interface IndependentLowerJawProps {
|
||||
executeChallenge: () => void;
|
||||
askSocrates: () => void;
|
||||
saveChallenge: () => void;
|
||||
attempts: number;
|
||||
tests: Test[];
|
||||
isSignedIn: boolean;
|
||||
challengeMeta: ChallengeMeta;
|
||||
@@ -106,6 +146,7 @@ export function IndependentLowerJaw({
|
||||
askSocrates,
|
||||
executeChallenge,
|
||||
saveChallenge,
|
||||
attempts,
|
||||
tests,
|
||||
isSignedIn,
|
||||
challengeMeta,
|
||||
@@ -120,6 +161,23 @@ export function IndependentLowerJaw({
|
||||
const submitChallenge = useSubmit();
|
||||
const firstFailedTest = tests.find(test => !!test.err);
|
||||
const hint = firstFailedTest?.message;
|
||||
const sanitizedHint = React.useMemo(
|
||||
() =>
|
||||
hint
|
||||
? sanitizeHtml(hint, {
|
||||
allowedTags: ['b', 'i', 'em', 'strong', 'code', 'wbr']
|
||||
})
|
||||
: '',
|
||||
[hint]
|
||||
);
|
||||
const hintAnnouncement = React.useMemo(
|
||||
() =>
|
||||
new DOMParser()
|
||||
.parseFromString(sanitizedHint, 'text/html')
|
||||
.body.textContent?.replace(/\s+/g, ' ')
|
||||
.trim() ?? '',
|
||||
[sanitizedHint]
|
||||
);
|
||||
const [showHint, setShowHint] = React.useState(false);
|
||||
const [showSocratesResults, setShowSocratesResults] = React.useState(false);
|
||||
const [showSubmissionHint, setShowSubmissionHint] = React.useState(true);
|
||||
@@ -143,10 +201,39 @@ export function IndependentLowerJaw({
|
||||
isBlockCompletedByIds || (hasCompletedPercent && completedPercent === 100);
|
||||
const showShareButton =
|
||||
isChallengeComplete && isLastStepInBlock && isBlockCompleted;
|
||||
const completionAnnouncement = [
|
||||
t('learn.congratulations-code-passes'),
|
||||
hasCompletedPercent
|
||||
? `${t(`intro:${challengeMeta.superBlock}.blocks.${challengeMeta.block}.title`)} ${t('learn.percent-complete', { percent: completedPercent })}`
|
||||
: null
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ');
|
||||
|
||||
const liveAnnouncementMessage =
|
||||
showHint && hint
|
||||
? hintAnnouncement
|
||||
: isChallengeComplete && showSubmissionHint
|
||||
? completionAnnouncement
|
||||
: '';
|
||||
|
||||
// Hint announcements need a fresh signal for every check attempt so the same
|
||||
// failing message can be remounted and announced again. Completion only needs
|
||||
// to announce when the challenge becomes complete, not on passing rerenders.
|
||||
const liveAnnouncementSignal =
|
||||
showHint && hint
|
||||
? attempts
|
||||
: isChallengeComplete && showSubmissionHint
|
||||
? isChallengeComplete
|
||||
: liveAnnouncementMessage;
|
||||
|
||||
const liveAnnouncementKey = liveAnnouncementMessage
|
||||
? `${challengeMeta.id}-${String(liveAnnouncementSignal)}`
|
||||
: `${challengeMeta.id}-idle`;
|
||||
|
||||
React.useEffect(() => {
|
||||
setShowHint(!!hint);
|
||||
}, [hint]);
|
||||
}, [hint, attempts]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!isChallengeComplete || !wasCheckButtonClicked) return;
|
||||
@@ -184,6 +271,10 @@ export function IndependentLowerJaw({
|
||||
data-playwright-test-label='independentLowerJaw-container'
|
||||
tabIndex={-1}
|
||||
>
|
||||
<StatusAnnouncement
|
||||
key={liveAnnouncementKey}
|
||||
message={liveAnnouncementMessage}
|
||||
/>
|
||||
{showHint && hint && (
|
||||
<div
|
||||
className='hint-container'
|
||||
@@ -204,9 +295,7 @@ export function IndependentLowerJaw({
|
||||
<div
|
||||
className='hint-body'
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: sanitizeHtml(hint, {
|
||||
allowedTags: ['b', 'i', 'em', 'strong', 'code', 'wbr']
|
||||
})
|
||||
__html: sanitizedHint
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user