Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Overlay option added in exercise component and new component IncludeRemoveQuestion created #86

Merged
merged 5 commits into from
Jan 27, 2025
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions src/components/Card.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -267,6 +267,7 @@ StepCard.displayName = 'OSStepCard';
export interface TaskStepCardProps extends SharedProps {
className?: string;
children?: ReactNode;
tabIndex?: number;
step: StepBase | StepWithData;
questionNumber: number;
numberOfQuestions: number;
Expand Down
81 changes: 80 additions & 1 deletion src/components/Exercise.spec.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { Exercise, ExerciseWithStepDataProps, ExerciseWithQuestionStatesProps } from './Exercise';
import { Exercise, ExerciseWithStepDataProps, ExerciseWithQuestionStatesProps, OverlayProps } from './Exercise';
import renderer from 'react-test-renderer';
import React from 'react';

Expand Down Expand Up @@ -299,4 +299,83 @@ describe('Exercise', () => {
expect(tree.toJSON()).toMatchSnapshot();
});
});

describe('with overlay rendering', () => {

let props: ExerciseWithStepDataProps & OverlayProps;

beforeEach(() => {
props = {
enableOverlay: true,
overlayChildren: <span>Overlay</span>,
exercise: {
uid: '1@1',
uuid: 'e4e27897-4abc-40d3-8565-5def31795edc',
group_uuid: '20e82bf6-232e-40c8-ba68-2d22c6498f69',
number: 1,
version: 1,
published_at: '2022-09-06T20:32:21.981Z',
context: 'Context',
stimulus_html: '<b>Stimulus HTML</b>',
tags: [],
authors: [{ user_id: 1, name: 'OpenStax' }],
copyright_holders: [{ user_id: 1, name: 'OpenStax' }],
derived_from: [],
is_vocab: false,
solutions_are_public: false,
versions: [1],
questions: [{
id: '1234@5',
collaborator_solutions: [],
formats: ['true-false'],
stimulus_html: '',
stem_html: '',
is_answer_order_important: false,
answers: [{
id: '1',
correctness: undefined,
content_html: 'True',
}, {
id: '2',
correctness: undefined,
content_html: 'False',
}],
}],
},
questionNumber: 1,
hasMultipleAttempts: false,
onAnswerChange: () => null,
onAnswerSave: () => null,
onNextStep: () => null,
canAnswer: false,
needsSaved: false,
apiIsPending: false,
canUpdateCurrentStep: false,
step: {
uid: '1234@4',
id: 1,
available_points: '1.0',
is_completed: false,
answer_id_order: ['1', '2'],
answer_id: '1',
free_response: '',
feedback_html: '',
correct_answer_id: '',
correct_answer_feedback_html: '',
is_feedback_available: true,
attempts_remaining: 0,
attempt_number: 1,
incorrectAnswerId: 0
},
numberOfQuestions: 1
}
});

it('matches snapshot', () => {
const tree = renderer.create(
<Exercise {...props} show_all_feedback />
).toJSON();
expect(tree).toMatchSnapshot();
});
});
});
151 changes: 150 additions & 1 deletion src/components/Exercise.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
ExerciseWithQuestionStatesProps,
} from './Exercise';
import { Answer } from '../types';
import { IncludeRemoveQuestion } from './IncludeRemoveQuestion';
import styled from 'styled-components';

const exerciseWithStepDataProps: ExerciseWithStepDataProps = {
Expand Down Expand Up @@ -144,6 +145,13 @@ const exerciseWithQuestionStatesProps = (): ExerciseWithQuestionStatesProps => {
}
};

const exerciseWithOverlayProps = (overlayChildren: React.ReactNode) => {
return {
enableOverlay: true,
overlayChildren,
};
}

type TextResizerValue = -2 | -1 | 0 | 1 | 2 | 3;
const textResizerScales = [0.75, 0.9, 1, 1.25, 1.5, 2];
const textResizerValues: TextResizerValue[] = [-2, -1, 0, 1, 2, 3];
Expand Down Expand Up @@ -522,7 +530,6 @@ export const MathJax = () => {
const [correctAnswerId, setCorrectAnswerId] = useState<number | undefined>(
undefined,
);

const props1: ExerciseWithQuestionStatesProps = {
...exerciseWithQuestionStatesProps(),
questionStates: {
Expand Down Expand Up @@ -807,3 +814,145 @@ export const PreviewCard = () => {
</TextResizerProvider>
);
};

export const OverlayCard = () => {
const randomlyCorrectAnswer = Math.floor(Math.random() * 3) + 1;
const props1: ExerciseWithQuestionStatesProps = {
...exerciseWithQuestionStatesProps(),
...exerciseWithOverlayProps(<button>Overlay</button>),
questionStates: {
'1': {
available_points: '1.0',
is_completed: true,
answer_id_order: ['1', '2', '3', '4'],
answer_id: randomlyCorrectAnswer,
free_response: '',
feedback_html: '',
correct_answer_id: randomlyCorrectAnswer.toString(),
correct_answer_feedback_html:
'Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat.',
attempts_remaining: 0,
attempt_number: 0,
incorrectAnswerId: 0,
canAnswer: false,
needsSaved: false,
apiIsPending: false,
solution: {
content_html:
'Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.',
solution_type: 'detailed',
},
},
},
exercise: {
tags: [
'book:stax-anp',
'context-cnxmod:bbaedbf4-4d78-4b7c-bc94-2a742f0f2f8c',
'lo:stax-anp:22-3-6',
'exid:stax-anp:1786',
'dok:1',
'time:short',
'book-slug:anatomy-and-physiology',
'book-slug:anatomy-and-physiology-2e',
'module-slug:anatomy-and-physiology:22-3-the-process-of-breathing',
'module-slug:anatomy-and-physiology-2e:22-3-the-process-of-breathing',
'assignment-type:reading',
'blooms:1',
'ost-type:concept-coach',
'assessment:preparedness:https://openstax.org/orn/book:page/4fd99458-6fdf-49bc-8688-a6dc17a1268d:11673dd9-55e6-46d9-8b78-b06df85246bd',
],
uuid: '8ae2b252-8943-4a1a-a123-2e6d9eeef4p5',
group_uuid: '19bd7035-d50b-42d8-8f8c-a4d72588a7aa',
number: 3030,
version: 4,
uid: '3030@4',
published_at: '2022-15-05T21:24:12.207Z',
solutions_are_public: false,
authors: [
{
user_id: 1,
name: 'OpenStax Exercises',
},
],
copyright_holders: [
{
user_id: 2,
name: 'Rice University',
},
],
derived_from: [],
is_vocab: false,
stimulus_html: '',
questions: [
{
id: 320733,
is_answer_order_important: true,
stimulus_html: '',
stem_html:
'Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat.',
answers: [
{
id: 832312,
content_html: 'Option 1',
},
{
id: 832313,
content_html: 'Option 2',
},
{
id: 832314,
content_html: 'Option 3',
},
{
id: 832315,
content_html: 'Option 4',
},
],
hints: [],
formats: ['free-response', 'multiple-choice'],
combo_choices: [],
},
],

versions: [4, 3, 2, 1],
},
};

const [buttonVariant, setButtonVariant] = React.useState<'include' | 'remove'>('include');

const props2: ExerciseWithQuestionStatesProps = {
...exerciseWithQuestionStatesProps(),
...exerciseWithOverlayProps(
<IncludeRemoveQuestion
buttonVariant={buttonVariant}
onIncludeHandler={() => setButtonVariant('remove')}
onRemoveHandler={() => setButtonVariant('include')}
/>
),
questionStates: {
'1': {
available_points: '1.0',
is_completed: true,
answer_id_order: ['1', '2'],
answer_id: undefined,
free_response: 'Free response',
feedback_html: 'Feedback',
correct_answer_id: '1',
correct_answer_feedback_html: 'Feedback for the correct answer',
attempts_remaining: 0,
attempt_number: 1,
incorrectAnswerId: 0,
canAnswer: false,
needsSaved: false,
apiIsPending: false,
},
},
};

return (
<TextResizerProvider>
<Exercise {...props1} className='preview-card' />
<Exercise {...props2} />
</TextResizerProvider>
);
};
63 changes: 49 additions & 14 deletions src/components/Exercise/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import { ExerciseToolbar, StyledToolbar } from '../ExerciseToolbar';
import { breakpoints } from '../../theme';
import { ExerciseHeaderIcons } from '../ExerciseHeaderIcons';
import { TypesetMathContext } from '../../hooks/useTypesetMath';
import { exerciseStyles } from './styles';
import { exerciseStyles, StyledOverlay } from './styles';

const StyledTaskStepCard = styled(TaskStepCard)`
font-size: calc(1.8rem * var(--content-text-scale));
Expand Down Expand Up @@ -55,18 +55,21 @@ const ToolbarWrapper = styled.div<{
`}
`;

const TaskStepCardWithToolbar = (props: React.PropsWithChildren<TaskStepCardProps> &
const TaskStepCardWithToolbar = React.forwardRef<HTMLDivElement, React.PropsWithChildren<TaskStepCardProps> &
Pick<ExerciseBaseProps, 'exerciseIcons'> & {
desktopToolbarEnabled: boolean;
mobileToolbarEnabled: boolean;
}
) => <ToolbarWrapper
desktopToolbarEnabled={props.desktopToolbarEnabled}
mobileToolbarEnabled={props.mobileToolbarEnabled}
>
<ExerciseToolbar icons={props.exerciseIcons} />
>((props, ref) => (
<ToolbarWrapper
ref={ref}
desktopToolbarEnabled={props.desktopToolbarEnabled}
mobileToolbarEnabled={props.mobileToolbarEnabled}
>
<ExerciseToolbar icons={props.exerciseIcons} />
<StyledTaskStepCard {...props} />
</ToolbarWrapper>;
</ToolbarWrapper>
));

const Preamble = ({ exercise }: { exercise: ExerciseData }) => {
return (
Expand Down Expand Up @@ -159,16 +162,24 @@ export interface ExerciseWithQuestionStatesProps extends ExerciseBaseProps {
onAnswerChange: (answer: Omit<Answer, 'id'> & { id: number, question_id: number }) => void;
}

export interface OverlayProps {
enableOverlay?: boolean;
overlayChildren?: React.ReactNode;
}

export const Exercise = styled(({
numberOfQuestions, questionNumber, step, exercise, show_all_feedback, scrollToQuestion, exerciseIcons, ...props
}: { className?: string } & (ExerciseWithStepDataProps | ExerciseWithQuestionStatesProps)) => {
numberOfQuestions, questionNumber, step, exercise, show_all_feedback, scrollToQuestion, exerciseIcons, enableOverlay = false, overlayChildren, ...props
}: { className?: string } & (ExerciseWithStepDataProps | ExerciseWithQuestionStatesProps) & OverlayProps) => {
const legacyStepRender = 'feedback_html' in step;
const questionsRef = React.useRef<Array<HTMLDivElement>>([]);
const container = React.useRef<HTMLDivElement>(null);
const hoverRef = React.useRef<HTMLDivElement>(null);

const [showOverlay, setShowOverlay] = React.useState<boolean>(false);

const typesetExercise = React.useCallback(() => {
if (container.current) {
typesetMath(container.current);
if (hoverRef.current) {
typesetMath(hoverRef.current);
}
}, []);

Expand All @@ -179,12 +190,19 @@ export const Exercise = styled(({
}
}, [scrollToQuestion, exercise]);

const handleBlur = (event: React.FocusEvent<HTMLDivElement>) => {
if (hoverRef.current && !hoverRef.current.contains(event.relatedTarget as Node)) {
setShowOverlay(false);
}
};

const desktopToolbarEnabled = Object.values(exerciseIcons || {}).some(({ location }) => location?.toolbar?.desktop);
const mobileToolbarEnabled = Object.values(exerciseIcons || {}).some(({ location }) => location?.toolbar?.mobile);

return <TypesetMathContext.Provider value={typesetExercise}>
<GlobalStyle />
<TaskStepCardWithToolbar
ref={container}
step={step}
questionNumber={questionNumber}
numberOfQuestions={legacyStepRender ? numberOfQuestions : exercise.questions.length}
Expand All @@ -195,15 +213,32 @@ export const Exercise = styled(({
{...(exerciseIcons ? { exerciseIcons: exerciseIcons } : null)}
className={props.className}
>
<div ref={container}>
<div
ref={hoverRef}
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think these need to be swapped right? So the hover happens anywhere inside the card? I think that also means that the tabindex needs to be placed on the TaskStepCardWithToolbar

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You're right

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Well actually, while I'm changing this, I see that TaskStepCardWithToolbar covers more than the visible card:

Screen.Recording.2025-01-23.at.11.32.33.AM.mov

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oh right there's some big margins on that... maybe we add a new wrapper for the toolbar and card body

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@jivey
In order to avoid modifications in margins or other styles coming from Card.tsx, I moved the Overlay logic inside Card component. This feature will be enable is a overlayChildren prop is not null and only Exercise can pass that prop for now. I tested that overlay jumps when hover all the card:

Screen.Recording.2025-01-23.at.2.30.54.PM.mov

tabIndex={enableOverlay ? 0 : -1} // This container is focusable only if enableOverlay is true
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Does this cause any side effects for non-overlay exercises in screenreaders? I'm wondering if we should be more conservative and not set the attribute at all for normal use.

{
...(enableOverlay
? {
onMouseOver: () => setShowOverlay(true),
onMouseLeave: () => setShowOverlay(false),
onFocus: () => setShowOverlay(true),
onBlur: handleBlur
}
: {})
}
>
{(enableOverlay && showOverlay) &&
<StyledOverlay>
{overlayChildren}
</StyledOverlay>}
<Preamble exercise={exercise} />

{exercise.questions.map((q, i) => {
const state = { ...(legacyStepRender ? step : props['questionStates'][q.id]) };
return (
<ExerciseQuestion
{...props}
{...{...state, available_points: undefined}}
{...{ ...state, available_points: undefined }}
ref={(el: HTMLDivElement) => questionsRef.current[questionNumber + i] = el}
exercise_uid={exercise.uid}
key={q.id}
Expand Down
Loading
Loading