diff --git a/client/src/templates/Challenges/components/independent-lower-jaw.test.tsx b/client/src/templates/Challenges/components/independent-lower-jaw.test.tsx index 7cf4035184f..853fb119bac 100644 --- a/client/src/templates/Challenges/components/independent-lower-jaw.test.tsx +++ b/client/src/templates/Challenges/components/independent-lower-jaw.test.tsx @@ -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('', () => { beforeEach(() => { showSocratesFlag = true; @@ -157,4 +165,134 @@ describe('', () => { 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 <main> here.', + message: 'Use <main> here.', + text: 'test', + testString: 'test' + } + ]; + + render( + , + createStore() + ); + + expect(getLiveRegion()).toHaveTextContent(''); + + await waitFor(() => + expect(getLiveRegion()).toHaveTextContent('Use
here.') + ); + }); + + it('re-announces the same hint after each check attempt', async () => { + const firstFailingTests: Test[] = [ + { + pass: false, + err: 'Use <main> here.', + message: 'Use <main> here.', + text: 'test', + testString: 'test' + } + ]; + const thirdFailingTests: Test[] = [ + { + pass: false, + err: 'Use <main> here.', + message: 'Use <main> here.', + text: 'test', + testString: 'test' + } + ]; + const secondFailingTests: Test[] = [ + { + pass: false, + err: 'Use <main> here.', + message: 'Use <main> here.', + text: 'test', + testString: 'test' + } + ]; + + const { rerender } = render( + , + createStore() + ); + + expect(getLiveRegion()).toHaveTextContent(''); + + await waitFor(() => + expect(getLiveRegion()).toHaveTextContent('Use
here.') + ); + + rerender( + + ); + + expect(getLiveRegion()).toHaveTextContent(''); + + await waitFor(() => + expect(getLiveRegion()).toHaveTextContent('Use
here.') + ); + + rerender( + + ); + + expect(getLiveRegion()).toHaveTextContent(''); + + await waitFor(() => + expect(getLiveRegion()).toHaveTextContent('Use
here.') + ); + }); + + it('announces completion text through a hidden live region', async () => { + render(, 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( + , + createStore() + ); + + await waitFor(() => + expect(getLiveRegion()).toHaveTextContent( + /learn\.congratulations-code-passes .* learn\.percent-complete/ + ) + ); + + rerender(); + + expect(getLiveRegion()).toHaveTextContent( + /learn\.congratulations-code-passes .* learn\.percent-complete/ + ); + }); }); diff --git a/client/src/templates/Challenges/components/independent-lower-jaw.tsx b/client/src/templates/Challenges/components/independent-lower-jaw.tsx index bcc9655ea76..be68941697e 100644 --- a/client/src/templates/Challenges/components/independent-lower-jaw.tsx +++ b/client/src/templates/Challenges/components/independent-lower-jaw.tsx @@ -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 ( + + {announcement} + + ); +}; + 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} > + {showHint && hint && (