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 && (