fix(a11y): update independent lower jaw to announce hint and completion messages (#67464)

This commit is contained in:
Huyen Nguyen
2026-05-28 06:57:20 +07:00
committed by GitHub
parent dd43266fd7
commit 64945a57a2
2 changed files with 233 additions and 6 deletions
@@ -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>&lt;main&gt;</code> here.',
message: 'Use <code>&lt;main&gt;</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>&lt;main&gt;</code> here.',
message: 'Use <code>&lt;main&gt;</code> here.',
text: 'test',
testString: 'test'
}
];
const thirdFailingTests: Test[] = [
{
pass: false,
err: 'Use <code>&lt;main&gt;</code> here.',
message: 'Use <code>&lt;main&gt;</code> here.',
text: 'test',
testString: 'test'
}
];
const secondFailingTests: Test[] = [
{
pass: false,
err: 'Use <code>&lt;main&gt;</code> here.',
message: 'Use <code>&lt;main&gt;</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>