feat: add ask for help button (#45636)

* feat: add ask for help button

* feat: move button to test output area

* feat: track number of attempts

* feat: add ask button to buttons container

* feat: restructure lower jaw

* feat: adjust the lower jaw styles

* fix: unmount component

* feat: restructure the lowerjaw

* fix: move aria live region to encompass both status and hint

* feat: rebase main

* feat: remove lower jaw update from test useEffect

* fix: output viewzone resize on output change

* fix: change encouragement message based on attemps

* fix: maintain lowerjaw height when tests run

* feat: remove set height and clear feedback

* fix: adjust lower jaw state on reset

* fix: focus submit button when challenge is completed

* fix: add aria-hidden after 500ms

* add translations

* fix: clean up

* feat: fade in test feedback after each attempt

* feat: make lower jaw accessible

* fix: clean up

Co-authored-by: ahmad abdolsaheb <ahmad.abdolsaheb@gmail.com>
Co-authored-by: Bruce B <bbsmooth@gmail.com>
Co-authored-by: Shaun Hamilton <shauhami020@gmail.com>
This commit is contained in:
Naomi Carrigan
2022-05-17 08:52:03 -07:00
committed by GitHub
parent 3503b473cc
commit 2fbdfa5d09
5 changed files with 442 additions and 241 deletions

View File

@@ -50,6 +50,7 @@
"reset-lesson": "Reset this lesson",
"run": "Run",
"run-test": "Run the Tests (Ctrl + Enter)",
"check-code": "Check Your Code (Ctrl + Enter)",
"reset": "Reset",
"reset-code": "Reset All Code",
"help": "Help",
@@ -268,6 +269,7 @@
"solution-link": "Solution Link",
"github-link": "GitHub Link",
"submit-and-go": "Submit and go to my next challenge",
"congradulations": "Congratulations, your code passes. Submit your code to complete this step and move on to the next one.",
"i-completed": "I've completed this challenge",
"test-output": "Your test output will go here",
"running-tests": "// running tests",
@@ -307,7 +309,14 @@
"complete-both-steps": "Complete both steps below to finish the challenge.",
"runs-in-vm": "The project runs in a virtual machine, complete the user stories described in there and get all the tests to pass to finish step 1.",
"completed": "Completed",
"not-started": "Not started"
"not-started": "Not started",
"hint": "Hint",
"test": "Test",
"sorry-try-again":"Sorry, your code does not pass. Try again.",
"sorry-keep-trying":"Sorry, your code does not pass. Keep trying.",
"sorry-getting-there":"Sorry, your code does not pass. You're getting there.",
"sorry-hang-in-there":"Sorry, your code does not pass. Hang in there.",
"sorry-dont-giveup":"Sorry, your code does not pass. Don't give up."
},
"donate": {
"title": "Support our nonprofit",
@@ -444,6 +453,7 @@
"fail": "Test Failed",
"not-passed": "Not Passed",
"passed": "Passed",
"hint": "Hint",
"heart": "Heart",
"initial": "Initial",
"info": "Intro Information",
@@ -463,7 +473,9 @@
"next-page": "Go to next page",
"last-page": "Go to last page",
"primary-nav": "primary",
"breadcrumb-nav": "breadcrumb"
"breadcrumb-nav": "breadcrumb",
"submit": "Use Ctrl + Enter to submit.",
"running-tests": "Running tests"
},
"flash": {
"honest-first": "To claim a certification, you must first accept our academic honesty policy",

View File

@@ -0,0 +1,44 @@
import React from 'react';
import { useTranslation } from 'react-i18next';
function LightBulb(
props: JSX.IntrinsicAttributes & React.SVGProps<SVGSVGElement>
): JSX.Element {
const { t } = useTranslation();
return (
<>
<svg
xmlns='http://www.w3.org/2000/svg'
width='50'
height='50'
viewBox='0 0 50 50'
fill='none'
{...props}
>
<g aria-hidden='true'>
<title>{t('icons.hint')}</title>
<path
d='M25 48.5C38.1168 48.5 48.75 37.8668 48.75 24.75C48.75 11.6332 38.1168 1 25 1C11.8832 1 1.25 11.6332 1.25 24.75C1.25 37.8668 11.8832 48.5 25 48.5Z'
fill='var(--primary-color)'
stroke='var(--primary-color)'
strokeWidth='0.25'
/>
<path
d='M31.3494 27.9901C33.0751 26.3241 35 24.4657 35 19.5C35 14 30.2467 10 25 10C19.7533 10 15 14 15 19.5C15 24.5098 16.6928 26.3561 18.2307 28.0335C19.4555 29.3695 20.0464 30.4781 20.1412 33.1128C23.0869 33.1553 26.0383 33.1713 28.9835 33.1128C28.9835 30.45 29.9475 29.3436 31.3494 27.9901Z'
fill='var(--primary-background)'
/>
<path
d='M20.1247 35.2025H28.9835C28.9835 35.2025 28.7011 38.7811 28.4835 39.2025C28.1437 39.8603 26.9835 41.7025 24.5404 41.7025C21.4835 41.7025 20.8417 39.8603 20.4835 39.2025C20.254 38.7811 20.1247 35.2025 20.1247 35.2025Z'
fill='var(--primary-background)'
/>
</g>
</svg>
</>
);
}
LightBulb.displayName = 'LightBulb';
export default LightBulb;

View File

@@ -29,7 +29,7 @@ textarea.inputarea {
}
.action-row button {
padding: 4px 16px;
padding: 6px 12px;
}
.active-tab {
@@ -50,9 +50,9 @@ textarea.inputarea {
.action-row-container,
.description-container {
background-color: var(--secondary-background);
padding: 10px;
padding: 1rem;
border: 2px solid var(--quaternary-background);
max-width: 700px;
max-width: 600px;
}
.description-container h1 {
@@ -89,32 +89,6 @@ textarea.inputarea {
margin: 0px;
}
#test-status {
padding-bottom: 5px;
padding-top: 5px;
}
#test-status .status {
font-family: revert;
}
#test-status p {
margin: 0.5rem 0 0;
}
#test-status h2.hint {
font-size: 1rem;
line-height: 1.5;
padding-right: 0.5rem;
/* using float instead of inline display so screen readers recognize h2 as a block element */
float: left;
margin: 0;
}
#test-status h2.hint::after {
content: ':';
}
.myEditableLineDecoration {
background-color: var(--gray-45);
width: 15px !important;
@@ -135,3 +109,64 @@ textarea.inputarea {
.accessibilityHelpWidget {
z-index: 1;
}
.test-feedback {
display: flex;
padding: 0;
flex-direction: column;
}
.test-feedback p {
margin: 0.5rem 0 0;
font-family: 'Lato', sans-serif;
}
.test-feedback h2 {
font-size: 1rem;
line-height: 1.5;
padding-right: 0.5rem;
/* using float instead of inline display so screen readers recognize h2 as a block element */
float: left;
margin: 0.5em 0 0;
}
.test-feedback h2:after {
content: ':';
}
.test-feedback svg {
height: 1.5rem;
width: auto;
margin-right: 0.5rem;
margin-top: 0.6rem;
}
.test-feedback > div {
margin-top: 1rem;
display: flex;
flex-direction: row;
}
.test-status-description,
.hint-description {
width: 100%;
}
.fade-in {
animation-name: FadeIn;
animation-duration: 0.5s;
transition-timing-function: linear;
}
@keyframes FadeIn {
0% {
opacity: 0;
}
/* keep test feedback contents initially hidden for 200ms*/
40% {
opacity: 0;
}
100% {
opacity: 1;
}
}

View File

@@ -9,16 +9,17 @@ import type {
import { highlightAllUnder } from 'prismjs';
import React, {
useEffect,
useState,
Suspense,
RefObject,
MutableRefObject,
useRef
} from 'react';
import ReactDOM from 'react-dom';
import { connect } from 'react-redux';
import { createSelector } from 'reselect';
import store from 'store';
import { debounce } from 'lodash';
import { Loader } from '../../../components/helpers';
import { Themes } from '../../../components/settings/theme';
import {
@@ -51,8 +52,10 @@ import {
initTests,
isResettingSelector,
stopResetting,
isProjectPreviewModalOpenSelector
isProjectPreviewModalOpenSelector,
openModal
} from '../redux';
import LowerJaw from './lower-jaw';
import './editor.css';
@@ -78,6 +81,7 @@ interface EditorProps {
initialTests: Test[];
isResetting: boolean;
isSignedIn: boolean;
openHelpModal: () => void;
output: string[];
resizeProps: ResizeProps;
saveChallenge: () => void;
@@ -156,7 +160,8 @@ const mapDispatchToProps = {
updateFile,
submitChallenge,
initTests,
stopResetting
stopResetting,
openHelpModal: () => openModal('help')
};
const modeMap = {
@@ -213,17 +218,6 @@ const initialData: EditorProperties = {
};
const Editor = (props: EditorProps): JSX.Element => {
const [timeoutHasElapsed, setTimeoutHasElapsed] = useState<boolean>(false);
const minTimeout = useRef<NodeJS.Timeout>();
useEffect(() => {
minTimeout.current = setTimeout(() => {
setTimeoutHasElapsed(true);
}, 1000 * 60);
return () => {
clearTimeout(minTimeout.current);
};
}, []);
const { editorRef, initTests } = props;
// These refs are used during initialisation of the editor as well as by
// callbacks. Since they have to be initialised before editorWillMount and
@@ -245,6 +239,7 @@ const Editor = (props: EditorProps): JSX.Element => {
noteIndex: 0,
shouldPlay: store.get('fcc-sound') as boolean | undefined
});
const attemptRef = useRef<{ attempts: number }>({ attempts: 0 });
// since editorDidMount runs once with the initial props object, it keeps a
// reference to *those* props. If we want it to use the latest props, we can
@@ -338,11 +333,11 @@ const Editor = (props: EditorProps): JSX.Element => {
const { challengeFiles, fileKey } = props;
const { model } = dataRef.current;
const newContents = challengeFiles?.find(
const initialContents = challengeFiles?.find(
challengeFile => challengeFile.fileKey === fileKey
)?.contents;
if (model?.getValue() !== newContents) {
model?.setValue(newContents ?? '');
if (model?.getValue() !== initialContents) {
model?.setValue(initialContents ?? '');
}
};
@@ -420,17 +415,10 @@ const Editor = (props: EditorProps): JSX.Element => {
run: () => {
if (props.usesMultifileEditor) {
if (challengeIsComplete()) {
const { submitChallenge } = props;
if (timeoutHasElapsed) {
submitChallenge();
} else {
debounce.current = setTimeout(() => {
submitChallenge();
}, 750);
}
debounce(props.submitChallenge, 2000);
} else {
clearTestFeedback();
props.executeChallenge();
attemptRef.current.attempts++;
}
} else {
props.executeChallenge({ showCompletionModal: true });
@@ -565,35 +553,66 @@ const Editor = (props: EditorProps): JSX.Element => {
dataRef.current.descriptionZoneId = changeAccessor.addZone(viewZone);
};
const outputZoneCallback = (
changeAccessor: editor.IViewZoneChangeAccessor
) => {
function createLowerJaw(outputNode: HTMLElement, callback?: () => void) {
const { output } = props;
const isChallengeComplete = challengeIsComplete();
const isEditorInFocus = document.activeElement?.tagName === 'TEXTAREA';
ReactDOM.render(
<LowerJaw
openHelpModal={props.openHelpModal}
executeChallenge={props.executeChallenge}
hint={output[1]}
testsLength={props.tests.length}
attemptsNumber={attemptRef.current.attempts}
challengeIsCompleted={isChallengeComplete}
challengeHasErrors={challengeHasErrors()}
submitChallenge={props.submitChallenge}
onAttempt={() => attemptRef.current.attempts++}
isEditorInFocus={isEditorInFocus}
/>,
outputNode,
callback
);
}
const updateOutputZone = () => {
const editor = dataRef.current.editor;
if (!editor) return;
const outputNode = createOutputNode(editor);
if (!editor || !dataRef.current.outputNode) return;
const outputNode = dataRef.current.outputNode;
createLowerJaw(outputNode, () => {
if (dataRef.current.outputNode) {
updateOutputViewZone(outputNode, editor);
}
});
};
const updateOutputViewZone = (
outputNode: HTMLDivElement,
editor: editor.IStandaloneCodeEditor
) => {
// make sure the overlayWidget has resized before using it to set the height
outputNode.style.width = `${editor.getLayoutInfo().contentWidth}px`;
// We have to wait for the viewZone to finish rendering before adjusting the
// position of the overlayWidget (i.e. trigger it via onComputedHeight). If
// not the editor may report the wrong value for position of the lines.
const viewZone = {
afterLineNumber: getLastLineOfEditableRegion(),
heightInPx: outputNode.offsetHeight,
domNode: document.createElement('div'),
onComputedHeight: () =>
dataRef.current.outputWidget &&
editor.layoutOverlayWidget(dataRef.current.outputWidget),
onDomNodeTop: (top: number) => {
dataRef.current.outputZoneTop = top;
if (dataRef.current.outputWidget)
editor.layoutOverlayWidget(dataRef.current.outputWidget);
}
};
dataRef.current.outputZoneId = changeAccessor.addZone(viewZone);
editor?.changeViewZones(changeAccessor => {
changeAccessor.removeZone(dataRef.current.outputZoneId);
const viewZone = {
afterLineNumber: getLastLineOfEditableRegion(),
heightInPx: outputNode.offsetHeight,
domNode: document.createElement('div'),
onComputedHeight: () =>
dataRef.current.outputWidget &&
editor.layoutOverlayWidget(dataRef.current.outputWidget),
onDomNodeTop: (top: number) => {
dataRef.current.outputZoneTop = top;
if (dataRef.current.outputWidget)
editor.layoutOverlayWidget(dataRef.current.outputWidget);
}
};
dataRef.current.outputZoneId = changeAccessor.addZone(viewZone);
});
};
function createDescription(editor: editor.IStandaloneCodeEditor) {
@@ -622,77 +641,20 @@ const Editor = (props: EditorProps): JSX.Element => {
return domNode;
}
function setTestFeedbackHeight(height?: number): void {
const testStatus = document.getElementById('test-status');
const newHeight = height === undefined ? 'auto' : `${height}px`;
if (testStatus) {
testStatus.style.height = newHeight;
}
}
function clearTestFeedback() {
const testStatus = document.getElementById('test-status');
if (testStatus && testStatus.innerHTML) {
// Explicitly set the height to what it currently is so that we
// don't get a big content shift every time the code is checked.
setTestFeedbackHeight(testStatus.offsetHeight);
testStatus.innerHTML = '';
}
}
function createOutputNode(editor: editor.IStandaloneCodeEditor) {
if (dataRef.current.outputNode) return dataRef.current.outputNode;
const outputNode = document.createElement('div');
const statusNode = document.createElement('div');
const editorActionRow = document.createElement('div');
editorActionRow.classList.add('action-row-container');
outputNode.classList.add('editor-lower-jaw');
outputNode.appendChild(editorActionRow);
statusNode.setAttribute('id', 'test-status');
statusNode.setAttribute('aria-live', 'assertive');
const button = document.createElement('button');
button.setAttribute('id', 'test-button');
button.classList.add('btn-block');
button.innerHTML = 'Check Your Code (Ctrl + Enter)';
const submitButton = document.createElement('button');
submitButton.setAttribute('id', 'submit-button');
submitButton.setAttribute('aria-hidden', 'true');
submitButton.innerHTML = 'Submit your code (Ctrl + Enter)';
submitButton.classList.add('btn-block');
editorActionRow.appendChild(button);
editorActionRow.appendChild(submitButton);
editorActionRow.appendChild(statusNode);
button.onclick = () => {
clearTestFeedback();
const { executeChallenge } = props;
executeChallenge();
};
outputNode.setAttribute('id', 'editor-lower-jaw');
outputNode.style.left = `${editor.getLayoutInfo().contentLeft}px`;
outputNode.style.width = `${editor.getLayoutInfo().contentWidth}px`;
outputNode.style.top = getOutputZoneTop();
dataRef.current.outputNode = outputNode;
return outputNode;
}
function resetOutputNode() {
function resetMarginDecorations() {
const { model, insideEditDecId } = dataRef.current;
const testButton = document.getElementById('test-button');
if (testButton) {
testButton.innerHTML = 'Check Your Code (Ctrl + Enter)';
testButton.onclick = () => {
clearTestFeedback();
props.executeChallenge();
};
}
// Must manually set test feedback height back to zero since
// clearTestFeedback does not.
setTestFeedbackHeight(0);
clearTestFeedback();
// Resetting margin decorations
const range = model?.getDecorationRange(insideEditDecId);
if (range) {
updateEditableRegion(range, { model });
@@ -931,7 +893,7 @@ const Editor = (props: EditorProps): JSX.Element => {
getOutputZoneTop
);
editor.addOverlayWidget(dataRef.current.outputWidget);
editor.changeViewZones(outputZoneCallback);
editor.changeViewZones(updateOutputZone);
}
editor.onDidScrollChange(() => {
@@ -1019,6 +981,10 @@ const Editor = (props: EditorProps): JSX.Element => {
return tests.some(test => test.err);
}
function resetAttampts() {
attemptRef.current.attempts = 0;
}
// runs every update to the editor and when the challenge is reset
useEffect(() => {
// If a challenge is reset, it needs to communicate that change to the
@@ -1042,12 +1008,9 @@ const Editor = (props: EditorProps): JSX.Element => {
if (props.isResetting) {
initializeDescriptionAndOutputWidgets();
updateDescriptionZone();
updateOutputZone();
showEditableRegion(editor);
// Since the outputNode is only reset when the step is restarted, users
// that want to try different solutions will need to do that.
resetOutputNode();
resetAttampts();
resetMarginDecorations();
}
}
// eslint-disable-next-line react-hooks/exhaustive-deps
@@ -1064,100 +1027,25 @@ const Editor = (props: EditorProps): JSX.Element => {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [props.previewOpen]);
const debounce = useRef<NodeJS.Timeout>();
useEffect(() => {
clearTimeout(debounce.current);
const { output } = props;
const { model, insideEditDecId } = dataRef.current;
const editableRegion = getEditableRegionFromRedux();
const isEditorInFocus = document.activeElement?.tagName === 'TEXTAREA';
if (editableRegion.length === 2) {
const testStatus = document.getElementById('test-status');
if (challengeIsComplete()) {
const testButton = document.getElementById('test-button');
// In case test button has focus, only visually hide it for now so we
// don't lose the focus before we set it on submit button.
testButton?.classList.add('sr-only');
const submitButton = document.getElementById('submit-button');
if (submitButton) {
submitButton.removeAttribute('aria-hidden');
submitButton.onclick = () => {
clearTestFeedback();
const { submitChallenge } = props;
if (timeoutHasElapsed) {
submitChallenge();
} else {
debounce.current = setTimeout(() => {
submitChallenge();
}, 750);
}
};
// Delay setting focus on submit button to ensure aria-live status
// message is announced first by screen reader.
setTimeout(() => {
// Must set focus on submit button before removing test button from
// accessibility API since test button might have focus.
if (!isEditorInFocus) {
submitButton.focus();
}
testButton?.setAttribute('aria-hidden', 'true');
}, 500);
const lowerJawElement = dataRef.current.outputNode;
const isChallengeComplete = challengeIsComplete();
const range = model?.getDecorationRange(insideEditDecId);
if (range && isChallengeComplete) {
updateEditableRegion(
range,
{ model },
{
linesDecorationsClassName: 'myEditableLineDecoration tests-passed'
}
const range = model?.getDecorationRange(insideEditDecId);
if (range) {
updateEditableRegion(
range,
{ model },
{
linesDecorationsClassName: 'myEditableLineDecoration tests-passed'
}
);
}
const submitKeyboardInstructions = isEditorInFocus
? '<span class="sr-only">Use Ctrl + Enter to submit.</span>'
: '';
if (testStatus) {
testStatus.innerHTML = `<p class="status"><span aria-hidden="true">&#9989;</span> Congratulations, your code passes. Submit your code to complete this step and move on to the next one. ${submitKeyboardInstructions}</p>`;
setTestFeedbackHeight();
}
} else if (challengeHasErrors() && testStatus) {
const wordsArray = [
'Try again.',
'Keep trying.',
"You're getting there.",
'Hang in there.',
"Don't give up."
];
testStatus.innerHTML = `<p class="status"><span aria-hidden="true">✖️</span> Sorry, your code does not pass. ${
wordsArray[Math.floor(Math.random() * wordsArray.length)]
}</p><div><h2 class="hint">Hint</h2> ${output[1]}</div>`;
setTestFeedbackHeight();
}
);
}
dataRef.current.outputNode = lowerJawElement;
updateOutputZone();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [props.tests]);
useEffect(() => {
const { output } = props;
// TODO: do we need this condition? What happens if the ref is empty?
if (dataRef.current.outputNode) {
// TODO: output gets wiped when the preview gets updated, keeping the
// display is an anti-pattern (the render should not ignore props!).
// The correct solution is probably to create a new redux variable
// (shownHint,maybe) and have that persist through previews. But, for
// now:
if (output) {
if (hasEditableRegion()) {
updateOutputZone();
}
}
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [props.output]);
useEffect(() => {
const editor = dataRef.current.editor;
editor?.layout();
@@ -1168,15 +1056,6 @@ const Editor = (props: EditorProps): JSX.Element => {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [props.dimensions]);
// TODO: DRY (there's going to be a lot of that)
function updateOutputZone() {
const editor = dataRef.current.editor;
editor?.changeViewZones(changeAccessor => {
changeAccessor.removeZone(dataRef.current.outputZoneId);
outputZoneCallback(changeAccessor);
});
}
function updateDescriptionZone() {
const editor = dataRef.current.editor;
editor?.changeViewZones(changeAccessor => {

View File

@@ -0,0 +1,231 @@
import React, { useState, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { debounce } from 'lodash';
import Fail from '../../../assets/icons/fail';
import LightBulb from '../../../assets/icons/lightbulb';
import GreenPass from '../../../assets/icons/green-pass';
interface LowerJawProps {
hint?: string;
challengeIsCompleted?: boolean;
openHelpModal: () => void;
executeChallenge: () => void;
submitChallenge: () => void;
showFeedback?: boolean;
isEditorInFocus?: boolean;
challengeHasErrors?: boolean;
testsLength?: number;
attemptsNumber?: number;
onAttempt?: () => void;
}
const LowerJaw = ({
openHelpModal,
challengeIsCompleted,
challengeHasErrors,
hint,
executeChallenge,
submitChallenge,
attemptsNumber,
testsLength,
onAttempt,
isEditorInFocus
}: LowerJawProps): JSX.Element => {
const [previousHint, setpreviousHint] = useState('');
const [runningTests, setRunningTests] = useState(false);
const [testFeedbackheight, setTestFeedbackheight] = useState(0);
const [isFeedbackHidden, setIsFeedbackHidden] = useState(false);
const [testBtnariaHidden, setTestBtnariaHidden] = useState(false);
const { t } = useTranslation();
const submitButtonRef = React.createRef<HTMLButtonElement>();
const testFeedbackRef = React.createRef<HTMLDivElement>();
useEffect(() => {
if (attemptsNumber && attemptsNumber > 0) {
//hide the feedback from SR untill the "Running tests" are displayed and removed.
setIsFeedbackHidden(true);
//allow the lower jaw height to be picked up by the editor.
setTimeout(() => {
setRunningTests(true);
}, 200);
//display the test feedback contents.
setTimeout(() => {
setRunningTests(false);
setIsFeedbackHidden(false);
}, 300);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [attemptsNumber]);
useEffect(() => {
// only save error hints
if (challengeHasErrors && hint && previousHint !== hint) {
setpreviousHint(hint);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [challengeHasErrors, hint]);
useEffect(() => {
if (challengeIsCompleted && submitButtonRef?.current) {
submitButtonRef.current.focus();
setTimeout(() => {
setTestBtnariaHidden(true);
}, 500);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [challengeIsCompleted]);
// eslint-disable-next-line react-hooks/exhaustive-deps
useEffect(() => {
if (testFeedbackRef.current) {
setTestFeedbackheight(testFeedbackRef.current.clientHeight);
}
});
const renderTestFeedbackContainer = () => {
if (attemptsNumber === 0) {
return '';
} else if (runningTests) {
return <span className='sr-only'>{t('aria.running-tests')}</span>;
} else if (challengeIsCompleted) {
const submitKeyboardInstructions = isEditorInFocus ? (
<span className='sr-only'>{t('aria.submit')}</span>
) : (
''
);
return (
<div className='test-status fade-in' aria-hidden={isFeedbackHidden}>
<div className='status-icon' aria-hidden='true'>
<span>
<GreenPass />
</span>
</div>
<div className='test-status-description'>
<h2>{t('learn.test')}</h2>
<p className='status'>
{t('learn.congradulations')}
{submitKeyboardInstructions}
</p>
</div>
</div>
);
// show the hint if the previousHint is not set
} else if (hint || previousHint) {
const hintDiscription = `<h2 class="hint">${t('learn.hint')}</h2> ${
hint || previousHint
}`;
return (
<>
<div className='test-status fade-in' aria-hidden={isFeedbackHidden}>
<div className='status-icon' aria-hidden='true'>
<span>
<Fail />
</span>
</div>
<div className='test-status-description'>
<h2>{t('learn.test')}</h2>
<p>{t(sentencePicker())}</p>
</div>
</div>
<div className='hint-status fade-in' aria-hidden={isFeedbackHidden}>
<div className='hint-icon' aria-hidden='true'>
<span>
<LightBulb />
</span>
</div>
<div
className='hint-description'
dangerouslySetInnerHTML={{ __html: hintDiscription }}
/>
</div>
</>
);
}
};
const sentencePicker = () => {
const sentenceArray = [
'learn.sorry-try-again',
'learn.sorry-keep-trying',
'learn.sorry-getting-there',
'learn.sorry-hang-in-there',
'learn.sorry-dont-giveup'
];
return attemptsNumber
? sentenceArray[attemptsNumber % sentenceArray.length]
: sentenceArray[0];
};
const onTestButtonClick = () => {
executeChallenge();
if (onAttempt) onAttempt();
};
const renderHelpButton = () => {
const isAtteptsLargerThanTest =
attemptsNumber && testsLength && attemptsNumber >= testsLength;
if (isAtteptsLargerThanTest && !challengeIsCompleted)
return (
<button
className='btn-block btn fade-in'
id='help-button'
onClick={openHelpModal}
>
{t('buttons.ask-for-help')}
</button>
);
};
const renderButtons = () => {
return (
<>
<button
id='test-button'
className={`btn-block btn ${challengeIsCompleted ? 'sr-only' : ''}`}
aria-hidden={testBtnariaHidden}
onClick={onTestButtonClick}
>
{t('buttons.check-code')}
</button>
<div id='action-buttons-container'>
<button
id='submit-button'
aria-hidden={!challengeIsCompleted}
className='btn-block btn'
onClick={debounce(submitChallenge, 2000)}
ref={submitButtonRef}
>
{t('buttons.submit-and-go')}
</button>
{renderHelpButton()}
</div>
</>
);
};
return (
<div className='action-row-container'>
{renderButtons()}
<div
style={runningTests ? { height: `${testFeedbackheight}px` } : {}}
className={`test-feedback`}
id='test-feedback'
aria-live='assertive'
ref={testFeedbackRef}
>
{renderTestFeedbackContainer()}
</div>
</div>
);
};
LowerJaw.displayName = 'LowerJaw';
export default LowerJaw;