Skip to content

Commit afec494

Browse files
authored
Overlay option added in exercise component and new component IncludeRemoveQuestion created (#86)
* overlay option added in exercise component and new component includeremovequestion created * resolve comments * update snapshots * move overlay logic into Card component * remove focus on card when overlay is up and add autoFocus for first interactive element on overlay
1 parent 57c1d7e commit afec494

12 files changed

+1948
-1091
lines changed

src/components/Card.tsx

Lines changed: 109 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { ReactNode } from "react";
1+
import { ReactNode, useState, useRef, useEffect, useCallback } from "react";
22
import { breakpoints, colors, layouts, mixins } from "../theme";
33
import { AvailablePoints, StepBase, StepWithData } from "../types";
44
import styled from "styled-components";
@@ -194,6 +194,21 @@ const StepCardQuestion = styled.div<{ unpadded?: boolean }>`
194194
}
195195
`;
196196

197+
export const StyledOverlay = styled.div`
198+
display: flex;
199+
flex-direction: column;
200+
justify-content: center;
201+
align-items: center;
202+
position: absolute;
203+
top: 50%;
204+
left: 50%;
205+
transform: translate(-50%, -50%);
206+
width: 100%;
207+
height: 100%;
208+
background-color: #FFFFFF80;
209+
z-index: 2;
210+
`;
211+
197212
interface SharedProps {
198213
questionNumber: number;
199214
numberOfQuestions: number;
@@ -212,6 +227,7 @@ export interface StepCardProps extends SharedProps {
212227
questionId?: string;
213228
multipartBadge?: ReactNode;
214229
isHomework: boolean;
230+
overlayChildren?: React.ReactNode;
215231
}
216232

217233
const StepCard = ({
@@ -229,35 +245,104 @@ const StepCard = ({
229245
leftHeaderChildren,
230246
rightHeaderChildren,
231247
headerTitleChildren,
248+
overlayChildren,
232249
...otherProps }: StepCardProps) => {
233250

251+
// Helps to stop focusing first child when is already focused
252+
const [previousFocusedElement, setPreviousFocusedElement] = useState<HTMLElement | null>(null);
253+
const overlayRef = useRef<HTMLDivElement>(null);
254+
const [showOverlay, setShowOverlay] = useState<boolean>(false);
255+
234256
const formattedQuestionNumber = numberOfQuestions > 1
235257
? `Questions ${questionNumber} - ${questionNumber + numberOfQuestions - 1}`
236258
: `Question ${questionNumber}`;
237259

260+
const handleOverlayBlur = (event: React.FocusEvent<HTMLDivElement>) => {
261+
if (overlayRef.current && !overlayRef.current.contains(event.relatedTarget as Node)) {
262+
setShowOverlay(false);
263+
}
264+
};
265+
266+
const handleOverlayFocus = useCallback((event: FocusEvent) => {
267+
setShowOverlay(true);
268+
const firstOverlayFocusableElement = document.getElementById('overlay-element')?.querySelector(
269+
'button, [href], input, select, textarea'
270+
) as HTMLElement;
271+
272+
if (
273+
(firstOverlayFocusableElement !== previousFocusedElement) &&
274+
(event.target === overlayRef.current)
275+
) {
276+
setPreviousFocusedElement(firstOverlayFocusableElement);
277+
firstOverlayFocusableElement.focus();
278+
}
279+
}, [overlayRef, previousFocusedElement]);
280+
281+
const hideFocusableElements = useCallback(() => {
282+
const focusableElements = Array.from(document.getElementById("step-card")?.querySelectorAll(
283+
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
284+
) || []);
285+
286+
focusableElements.forEach((el) => {
287+
(el as HTMLElement).setAttribute('tabindex', '-1');
288+
});
289+
}, []);
290+
291+
useEffect(() => {
292+
const currentOverlayRef = overlayRef.current;
293+
if (currentOverlayRef && overlayChildren) {
294+
currentOverlayRef.addEventListener('focus', handleOverlayFocus);
295+
hideFocusableElements();
296+
}
297+
return () => {
298+
currentOverlayRef?.removeEventListener('focus', handleOverlayFocus);
299+
};
300+
}, [overlayChildren, overlayRef, handleOverlayFocus, hideFocusableElements]);
301+
238302
return (
239303
<OuterStepCard {...otherProps}>
240304
{multipartBadge}
241305
<InnerStepCard className={className}>
242-
{questionNumber && isHomework && stepType === 'exercise' &&
243-
<StepCardHeader className="step-card-header">
244-
<div>
245-
{leftHeaderChildren}
246-
<h2 className="question-info">
247-
{headerTitleChildren}
248-
<span>{formattedQuestionNumber}</span>
249-
{showTotalQuestions ? <span className="num-questions">&nbsp;/ {numberOfQuestions}</span> : null}
250-
<span className="separator">|</span>
251-
<span className="question-id">ID: {questionId}</span>
252-
</h2>
253-
</div>
254-
{availablePoints || rightHeaderChildren ? <div>
255-
{availablePoints && <div className="points">{availablePoints} Points</div>}
256-
{rightHeaderChildren}
257-
</div> : null}
258-
</StepCardHeader>
259-
}
260-
<StepCardQuestion unpadded={unpadded}>{children}</StepCardQuestion>
306+
<div
307+
ref={overlayRef}
308+
{
309+
...(overlayChildren
310+
? {
311+
onMouseOver: () => setShowOverlay(true),
312+
onMouseLeave: () => setShowOverlay(false),
313+
onBlur: handleOverlayBlur,
314+
tabIndex: 0,
315+
}
316+
: {})
317+
}
318+
>
319+
{(overlayChildren && showOverlay) &&
320+
<StyledOverlay id="overlay-element">
321+
{overlayChildren}
322+
</StyledOverlay>
323+
}
324+
<div id="step-card">
325+
{questionNumber && isHomework && stepType === 'exercise' &&
326+
<StepCardHeader className="step-card-header">
327+
<div>
328+
{leftHeaderChildren}
329+
<h2 className="question-info">
330+
{headerTitleChildren}
331+
<span>{formattedQuestionNumber}</span>
332+
{showTotalQuestions ? <span className="num-questions">&nbsp;/ {numberOfQuestions}</span> : null}
333+
<span className="separator">|</span>
334+
<span className="question-id">ID: {questionId}</span>
335+
</h2>
336+
</div>
337+
{availablePoints || rightHeaderChildren ? <div>
338+
{availablePoints && <div className="points">{availablePoints} Points</div>}
339+
{rightHeaderChildren}
340+
</div> : null}
341+
</StepCardHeader>
342+
}
343+
<StepCardQuestion unpadded={unpadded}>{children}</StepCardQuestion>
344+
</div>
345+
</div>
261346
</InnerStepCard>
262347
</OuterStepCard>
263348
)
@@ -267,9 +352,11 @@ StepCard.displayName = 'OSStepCard';
267352
export interface TaskStepCardProps extends SharedProps {
268353
className?: string;
269354
children?: ReactNode;
355+
tabIndex?: number;
270356
step: StepBase | StepWithData;
271357
questionNumber: number;
272358
numberOfQuestions: number;
359+
overlayChildren?: React.ReactNode;
273360
}
274361

275362
const TaskStepCard = ({
@@ -278,6 +365,7 @@ const TaskStepCard = ({
278365
numberOfQuestions,
279366
children,
280367
className,
368+
overlayChildren,
281369
...otherProps
282370
}: TaskStepCardProps) =>
283371
(<StepCard {...otherProps}
@@ -291,6 +379,7 @@ const TaskStepCard = ({
291379
// availablePoints={step.available_points}
292380
className={cn(`${('type' in step ? step.type : 'exercise')}-step`, className)}
293381
questionId={step.uid}
382+
overlayChildren={overlayChildren}
294383
>
295384
{children}
296385
</StepCard>);

src/components/Exercise.spec.tsx

Lines changed: 79 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { Exercise, ExerciseWithStepDataProps, ExerciseWithQuestionStatesProps } from './Exercise';
1+
import { Exercise, ExerciseWithStepDataProps, ExerciseWithQuestionStatesProps, OverlayProps } from './Exercise';
22
import renderer from 'react-test-renderer';
33
import React from 'react';
44

@@ -299,4 +299,82 @@ describe('Exercise', () => {
299299
expect(tree.toJSON()).toMatchSnapshot();
300300
});
301301
});
302+
303+
describe('with overlay rendering', () => {
304+
305+
let props: ExerciseWithStepDataProps & OverlayProps;
306+
307+
beforeEach(() => {
308+
props = {
309+
overlayChildren: <span>Overlay</span>,
310+
exercise: {
311+
uid: '1@1',
312+
uuid: 'e4e27897-4abc-40d3-8565-5def31795edc',
313+
group_uuid: '20e82bf6-232e-40c8-ba68-2d22c6498f69',
314+
number: 1,
315+
version: 1,
316+
published_at: '2022-09-06T20:32:21.981Z',
317+
context: 'Context',
318+
stimulus_html: '<b>Stimulus HTML</b>',
319+
tags: [],
320+
authors: [{ user_id: 1, name: 'OpenStax' }],
321+
copyright_holders: [{ user_id: 1, name: 'OpenStax' }],
322+
derived_from: [],
323+
is_vocab: false,
324+
solutions_are_public: false,
325+
versions: [1],
326+
questions: [{
327+
id: '1234@5',
328+
collaborator_solutions: [],
329+
formats: ['true-false'],
330+
stimulus_html: '',
331+
stem_html: '',
332+
is_answer_order_important: false,
333+
answers: [{
334+
id: '1',
335+
correctness: undefined,
336+
content_html: 'True',
337+
}, {
338+
id: '2',
339+
correctness: undefined,
340+
content_html: 'False',
341+
}],
342+
}],
343+
},
344+
questionNumber: 1,
345+
hasMultipleAttempts: false,
346+
onAnswerChange: () => null,
347+
onAnswerSave: () => null,
348+
onNextStep: () => null,
349+
canAnswer: false,
350+
needsSaved: false,
351+
apiIsPending: false,
352+
canUpdateCurrentStep: false,
353+
step: {
354+
uid: '1234@4',
355+
id: 1,
356+
available_points: '1.0',
357+
is_completed: false,
358+
answer_id_order: ['1', '2'],
359+
answer_id: '1',
360+
free_response: '',
361+
feedback_html: '',
362+
correct_answer_id: '',
363+
correct_answer_feedback_html: '',
364+
is_feedback_available: true,
365+
attempts_remaining: 0,
366+
attempt_number: 1,
367+
incorrectAnswerId: 0
368+
},
369+
numberOfQuestions: 1
370+
}
371+
});
372+
373+
it('matches snapshot', () => {
374+
const tree = renderer.create(
375+
<Exercise {...props} show_all_feedback />
376+
).toJSON();
377+
expect(tree).toMatchSnapshot();
378+
});
379+
});
302380
});

0 commit comments

Comments
 (0)