Skip to content

Commit 86c1455

Browse files
Core 422 419 answer choice accessibility (#78)
* Use radio buttons for answer choices - Use radio buttons instead of push buttons for answer choices. Many screen readers have shortcuts to cycle between controls of a type. For example, some will cycle between radio buttons with (r) and push buttons with (b). Radio buttons also allow changing answer choices via the cursor keys without focus leaving the radio group. This also outsources quite a few accessibility requirements to the browser. - Add focus outline to custom radio buttons For accessibility, focusable elements should have a visually distinct outline when focused. - Obscure radio buttons in a way that works with screen readers. `display: none` causes screen readers to ignore the element. - Use `aria-details` to tie the feedback html to the answer. `aria-details` is preferred over `aria-describedby` when the associated element may contain something other than plain text. - Add role=radiogroup to answers-table. Many screen readers have shortcuts to cycle between radio groups. Screen readers also read the label of the radio group when it gains focus. - Set width/height of answer-input-box to 1px In react-aria-components, radio buttons are placed inside 1x1 spans with overflow hidden. This change is an attempt to copy that without nesting the radio button inside of a span. * Remove duplicated correctness indicator from label AnswerIndicator adds correctness to the label. * Update snapshots * Create and use visuallyHidden mixin for answer-input-box * Use html variable to determine if there is feedback * Refactor Answer component Split it into discrete components * Update snapshots className changes are from the new `visuallyHidden` mixin
1 parent f332eea commit 86c1455

11 files changed

+618
-848
lines changed

src/components/Answer.tsx

Lines changed: 150 additions & 116 deletions
Original file line numberDiff line numberDiff line change
@@ -45,27 +45,163 @@ export interface AnswerProps {
4545
contentRenderer?: JSX.Element;
4646
show_all_feedback?: boolean;
4747
tableFeedbackEnabled?: boolean;
48+
feedbackId?: string;
4849
}
4950

50-
export const Answer = (props: AnswerProps) => {
51+
type AnswerAnswerProps = Pick<
52+
AnswerBodyProps,
53+
'answer' |
54+
'contentRenderer' |
55+
'show_all_feedback' |
56+
'tableFeedbackEnabled' |
57+
'isCorrect' |
58+
'isIncorrect'
59+
>;
60+
61+
const AnswerAnswer = (props: AnswerAnswerProps) => {
62+
const {
63+
answer: { content_html, feedback_html },
64+
contentRenderer,
65+
show_all_feedback,
66+
tableFeedbackEnabled,
67+
isCorrect,
68+
isIncorrect,
69+
} = props;
70+
return (
71+
<div className="answer-answer">
72+
<AnswerIndicator isCorrect={isCorrect} isIncorrect={isIncorrect} />
73+
<Content className="answer-content" component={contentRenderer} html={content_html} />
74+
{show_all_feedback && feedback_html && !tableFeedbackEnabled &&
75+
<SimpleFeedback key="question-mc-feedback" contentRenderer={contentRenderer}>
76+
{feedback_html}
77+
</SimpleFeedback>}
78+
</div>
79+
)
80+
}
81+
82+
interface AnswerBodyProps extends AnswerProps {
83+
isCorrect?: boolean;
84+
isSelected?: boolean;
85+
isIncorrect?: boolean;
86+
}
87+
88+
const TeacherReview = (props: AnswerBodyProps) => {
89+
const {
90+
answer,
91+
answered_count,
92+
isCorrect,
93+
contentRenderer,
94+
iter,
95+
show_all_feedback,
96+
tableFeedbackEnabled,
97+
} = props;
98+
const percent = answer.selected_count && answered_count
99+
? Math.round((answer.selected_count / answered_count) * 100)
100+
: 0;
101+
return (
102+
<div className="review-wrapper">
103+
<div className={cn('review-count', { 'green': isCorrect, 'red': !isCorrect })}>
104+
<span
105+
className="selected-count"
106+
data-percent={`${percent}`}
107+
>
108+
{answer.selected_count}
109+
</span>
110+
<span className={cn('letter', { 'green': isCorrect, 'red': !isCorrect })}>
111+
{ALPHABET[iter]}
112+
</span>
113+
</div>
114+
<AnswerAnswer
115+
answer={answer}
116+
contentRenderer={contentRenderer}
117+
show_all_feedback={show_all_feedback}
118+
tableFeedbackEnabled={tableFeedbackEnabled} />
119+
</div>
120+
);
121+
}
122+
123+
const AnswerChoice = (props: AnswerBodyProps) => {
51124
const {
52125
type,
53126
iter,
54127
answer,
55128
disabled,
56129
onKeyPress,
57130
qid,
58-
answerId,
59-
correctAnswerId,
60-
incorrectAnswerId,
61-
hasCorrectAnswer,
62-
answered_count,
63131
contentRenderer,
132+
correctIncorrectIcon,
133+
feedbackId,
134+
isSelected,
135+
isCorrect,
136+
isIncorrect,
137+
hasCorrectAnswer,
64138
show_all_feedback,
65139
tableFeedbackEnabled,
66140
} = props;
141+
const ariaLabel = `${isSelected ? 'Selected ' : ''}Choice ${ALPHABET[iter]}:`;
142+
let onChangeAnswer: AnswerProps['onChangeAnswer'];
143+
144+
const onChange = () => onChangeAnswer && onChangeAnswer(answer);
145+
146+
if (!hasCorrectAnswer
147+
&& (type !== 'teacher-review')
148+
&& (type !== 'teacher-preview')
149+
&& (type !== 'student-mpp')) {
150+
({ onChangeAnswer } = props);
151+
}
67152

68-
let body, feedback, selectedCount;
153+
return <>
154+
{type === 'teacher-preview' &&
155+
<div className="correct-incorrect">
156+
{isCorrect && correctIncorrectIcon}
157+
</div>}
158+
<input
159+
type="radio"
160+
className="answer-input-box"
161+
checked={isSelected}
162+
id={`${qid}-option-${iter}`}
163+
name={`${qid}-options`}
164+
onChange={onChange}
165+
disabled={disabled || !onChangeAnswer}
166+
aria-details={feedbackId}
167+
/>
168+
<label
169+
onKeyPress={onKeyPress}
170+
htmlFor={`${qid}-option-${iter}`}
171+
className="answer-label">
172+
<span
173+
className="answer-letter-wrapper"
174+
aria-label={ariaLabel}
175+
data-answer-choice={ALPHABET[iter]}
176+
data-test-id={`answer-choice-${ALPHABET[iter]}`}
177+
>
178+
</span>
179+
<AnswerAnswer
180+
answer={answer}
181+
contentRenderer={contentRenderer}
182+
show_all_feedback={show_all_feedback}
183+
tableFeedbackEnabled={tableFeedbackEnabled}
184+
isCorrect={isCorrect}
185+
isIncorrect={isIncorrect} />
186+
</label>
187+
</>
188+
}
189+
190+
const AnswerBody = (props: AnswerBodyProps) => {
191+
return props.type === 'teacher-review'
192+
? <TeacherReview {...props} />
193+
: <AnswerChoice {...props} />
194+
}
195+
196+
export const Answer = (props: AnswerProps) => {
197+
const {
198+
type,
199+
answer,
200+
disabled,
201+
answerId,
202+
correctAnswerId,
203+
incorrectAnswerId,
204+
} = props;
69205

70206
const isChecked = isAnswerChecked(answer, answerId);
71207
const isCorrect = isAnswerCorrect(answer, correctAnswerId);
@@ -78,124 +214,22 @@ export const Answer = (props: AnswerProps) => {
78214
// incorrectAnswerId will be empty.
79215
const isPreviousResponse = answerId === undefined && (!incorrectAnswerId && isCorrect || isIncorrect);
80216

217+
const isSelected = isChecked || isPreviousResponse;
81218
const classes = cn('answers-answer', {
82219
'disabled': disabled,
83-
'answer-selected': isChecked || isPreviousResponse,
220+
'answer-selected': isSelected,
84221
'answer-correct': isCorrect && type !== 'student-mpp',
85222
'answer-incorrect': incorrectAnswerId && isAnswerIncorrect(answer, incorrectAnswerId),
86223
});
87224

88-
const correctIncorrectIcon = (
89-
<div className="correct-incorrect">
90-
{isCorrect && props.correctIncorrectIcon}
91-
</div>
92-
);
93-
94-
let ariaLabel = `${isChecked ? 'Selected ' : ''}Choice ${ALPHABET[iter]}`;
95-
// somewhat misleading - this means that there is a correct answer,
96-
// not necessarily that this answer is correct
97-
if (hasCorrectAnswer) {
98-
ariaLabel += `(${isCorrect ? 'Correct' : 'Incorrect'} Answer)`;
99-
}
100-
ariaLabel += ':';
101-
102-
let onChangeAnswer: AnswerProps['onChangeAnswer'], radioBox;
103-
104-
const onChange = () => onChangeAnswer && onChangeAnswer(answer);
105-
106-
if (!hasCorrectAnswer
107-
&& (type !== 'teacher-review')
108-
&& (type !== 'teacher-preview')
109-
&& (type !== 'student-mpp')) {
110-
({ onChangeAnswer } = props);
111-
}
112-
113-
if (onChangeAnswer) {
114-
radioBox = (
115-
<input
116-
type="radio"
117-
className="answer-input-box"
118-
checked={isChecked}
119-
id={`${qid}-option-${iter}`}
120-
name={`${qid}-options`}
121-
onChange={onChange}
122-
disabled={disabled}
123-
/>
124-
);
125-
}
126-
127-
if (show_all_feedback && answer.feedback_html && !tableFeedbackEnabled) {
128-
feedback = (
129-
<SimpleFeedback key="question-mc-feedback" contentRenderer={contentRenderer}>
130-
{answer.feedback_html}
131-
</SimpleFeedback>
132-
);
133-
}
134-
135-
if (type === 'teacher-review') {
136-
let percent = 0;
137-
if (answer.selected_count && answered_count) {
138-
percent = Math.round((answer.selected_count / answered_count) * 100);
139-
}
140-
selectedCount = (
141-
<span
142-
className="selected-count"
143-
data-percent={`${percent}`}
144-
>
145-
{answer.selected_count}
146-
</span>
147-
);
148-
149-
body = (
150-
<div className="review-wrapper">
151-
<div className={cn('review-count', { 'green': isCorrect, 'red': !isCorrect })}>
152-
{selectedCount}
153-
<span className={cn('letter', { 'green': isCorrect, 'red': !isCorrect })}>
154-
{ALPHABET[iter]}
155-
</span>
156-
</div>
157-
158-
<div className="answer-answer">
159-
<Content className="answer-content" component={contentRenderer} html={answer.content_html} />
160-
{feedback}
161-
</div>
162-
</div>
163-
);
164-
} else {
165-
body = (
166-
<>
167-
{type === 'teacher-preview' && correctIncorrectIcon}
168-
{selectedCount}
169-
{radioBox}
170-
<label
171-
onKeyPress={onKeyPress}
172-
htmlFor={`${qid}-option-${iter}`}
173-
className="answer-label">
174-
<span className="answer-letter-wrapper">
175-
<button
176-
onClick={onChange}
177-
aria-label={ariaLabel}
178-
className="answer-letter"
179-
disabled={disabled || isIncorrect}
180-
data-test-id={`answer-choice-${ALPHABET[iter]}`}
181-
>
182-
{ALPHABET[iter]}
183-
</button>
184-
</span>
185-
<div className="answer-answer">
186-
<AnswerIndicator isCorrect={isCorrect} isIncorrect={isIncorrect} />
187-
<Content className="answer-content" component={contentRenderer} html={answer.content_html} />
188-
{feedback}
189-
</div>
190-
</label>
191-
</>
192-
);
193-
}
194-
195225
return (
196226
<div className="openstax-answer">
197227
<section className={classes}>
198-
{body}
228+
<AnswerBody
229+
{...props}
230+
isCorrect={isCorrect}
231+
isSelected={isSelected}
232+
isIncorrect={isIncorrect} />
199233
</section>
200234
</div>
201235
);

src/components/AnswersTable.spec.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -114,7 +114,8 @@ describe('AnswersTable', () => {
114114
const tree = renderer.create(
115115
<AnswersTable {...props} question={{...props.question, id: ''}} />
116116
);
117-
expect(tree.root.findAllByProps({ qid: 'auto-0' }).length).toBe(2);
117+
// 2 answers * 3 times the prop is passed down (Answer -> AnswerBody -> RadioAnswer)
118+
expect(tree.root.findAllByProps({ qid: 'auto-0' }).length).toBe(6);
118119
});
119120

120121
it('defaults type and show_all_feedback', () => {

src/components/AnswersTable.tsx

Lines changed: 15 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,7 @@ export const AnswersTable = (props: AnswersTableProps) => {
3737

3838
const { id } = question;
3939

40-
const feedback: { index: number, html: string }[] = [];
40+
const feedback: { index: number, html: string, id: string }[] = [];
4141

4242
const sortedAnswersByIdOrder = (idOrder: ID[]) => {
4343
const { answers } = question;
@@ -69,34 +69,41 @@ export const AnswersTable = (props: AnswersTableProps) => {
6969
question_id: typeof question.id === 'string' ? parseInt(question.id, 10) : question.id
7070
},
7171
iter: i,
72-
key: `${questionAnswerProps.qid}-option-${i}`
72+
key: `${questionAnswerProps.qid}-option-${i}`,
7373
};
7474
const answerProps = Object.assign({}, additionalProps, questionAnswerProps);
75+
let html: string | undefined;
76+
let feedbackId: string | undefined;
7577

7678
if (show_all_feedback && answer.feedback_html && tableFeedbackEnabled) {
77-
feedback.push({ index: i, html: answer.feedback_html })
79+
html = answer.feedback_html;
7880
} else if (answer.id === incorrectAnswerId && feedback_html) {
79-
feedback.push({ index: i, html: feedback_html })
81+
html = feedback_html;
8082
} else if (answer.id === correct_answer_id && correct_answer_feedback_html) {
81-
feedback.push({ index: i, html: correct_answer_feedback_html })
83+
html = correct_answer_feedback_html;
84+
}
85+
86+
if (html) {
87+
feedbackId = `feedback-${questionAnswerProps.qid}-${i}`
88+
feedback.push({ index: i, html, id: feedbackId });
8289
}
8390

8491
return (
85-
<Answer {...answerProps} />
92+
<Answer feedbackId={feedbackId} {...answerProps} />
8693
);
8794
});
8895

8996
feedback.forEach((item, i) => {
9097
const spliceIndex = item.index + i + 1;
9198
answersHtml.splice(spliceIndex, 0, (
92-
<Feedback key={spliceIndex} contentRenderer={props.contentRenderer}>
99+
<Feedback id={item.id} key={spliceIndex} contentRenderer={props.contentRenderer}>
93100
{item.html}
94101
</Feedback>
95102
));
96103
});
97104

98105
return (
99-
<div className="answers-table">
106+
<div role="radiogroup" aria-label="Answer choices" className="answers-table">
100107
{instructions}
101108
{answersHtml}
102109
</div>

src/components/Feedback.tsx

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ interface FeedbackProps {
66
children: string;
77
className?: string;
88
contentRenderer?: JSX.Element;
9+
id: string
910
}
1011

1112
const SimpleFeedback = (props: Pick<FeedbackProps, 'children' | 'className' | 'contentRenderer'>) => (
@@ -18,12 +19,12 @@ const SimpleFeedback = (props: Pick<FeedbackProps, 'children' | 'className' | 'c
1819
</aside>
1920
);
2021

21-
const Feedback = (props: FeedbackProps) => {
22+
const Feedback = ({ id, ...props }: FeedbackProps) => {
2223
const position = props.position || 'bottom';
2324
const wrapperClasses = classnames('question-feedback', position);
2425

2526
return (
26-
<aside className={wrapperClasses}>
27+
<aside id={id} className={wrapperClasses}>
2728
<div className="arrow" aria-label="Answer Feedback" />
2829
<SimpleFeedback {...props}>
2930
{props.children}

0 commit comments

Comments
 (0)