Skip to content

Commit

Permalink
Core 422 419 answer choice accessibility (#78)
Browse files Browse the repository at this point in the history
* 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
  • Loading branch information
TylerZeroMaster authored Dec 10, 2024
1 parent f332eea commit 86c1455
Show file tree
Hide file tree
Showing 11 changed files with 618 additions and 848 deletions.
266 changes: 150 additions & 116 deletions src/components/Answer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -45,27 +45,163 @@ export interface AnswerProps {
contentRenderer?: JSX.Element;
show_all_feedback?: boolean;
tableFeedbackEnabled?: boolean;
feedbackId?: string;
}

export const Answer = (props: AnswerProps) => {
type AnswerAnswerProps = Pick<
AnswerBodyProps,
'answer' |
'contentRenderer' |
'show_all_feedback' |
'tableFeedbackEnabled' |
'isCorrect' |
'isIncorrect'
>;

const AnswerAnswer = (props: AnswerAnswerProps) => {
const {
answer: { content_html, feedback_html },
contentRenderer,
show_all_feedback,
tableFeedbackEnabled,
isCorrect,
isIncorrect,
} = props;
return (
<div className="answer-answer">
<AnswerIndicator isCorrect={isCorrect} isIncorrect={isIncorrect} />
<Content className="answer-content" component={contentRenderer} html={content_html} />
{show_all_feedback && feedback_html && !tableFeedbackEnabled &&
<SimpleFeedback key="question-mc-feedback" contentRenderer={contentRenderer}>
{feedback_html}
</SimpleFeedback>}
</div>
)
}

interface AnswerBodyProps extends AnswerProps {
isCorrect?: boolean;
isSelected?: boolean;
isIncorrect?: boolean;
}

const TeacherReview = (props: AnswerBodyProps) => {
const {
answer,
answered_count,
isCorrect,
contentRenderer,
iter,
show_all_feedback,
tableFeedbackEnabled,
} = props;
const percent = answer.selected_count && answered_count
? Math.round((answer.selected_count / answered_count) * 100)
: 0;
return (
<div className="review-wrapper">
<div className={cn('review-count', { 'green': isCorrect, 'red': !isCorrect })}>
<span
className="selected-count"
data-percent={`${percent}`}
>
{answer.selected_count}
</span>
<span className={cn('letter', { 'green': isCorrect, 'red': !isCorrect })}>
{ALPHABET[iter]}
</span>
</div>
<AnswerAnswer
answer={answer}
contentRenderer={contentRenderer}
show_all_feedback={show_all_feedback}
tableFeedbackEnabled={tableFeedbackEnabled} />
</div>
);
}

const AnswerChoice = (props: AnswerBodyProps) => {
const {
type,
iter,
answer,
disabled,
onKeyPress,
qid,
answerId,
correctAnswerId,
incorrectAnswerId,
hasCorrectAnswer,
answered_count,
contentRenderer,
correctIncorrectIcon,
feedbackId,
isSelected,
isCorrect,
isIncorrect,
hasCorrectAnswer,
show_all_feedback,
tableFeedbackEnabled,
} = props;
const ariaLabel = `${isSelected ? 'Selected ' : ''}Choice ${ALPHABET[iter]}:`;
let onChangeAnswer: AnswerProps['onChangeAnswer'];

const onChange = () => onChangeAnswer && onChangeAnswer(answer);

if (!hasCorrectAnswer
&& (type !== 'teacher-review')
&& (type !== 'teacher-preview')
&& (type !== 'student-mpp')) {
({ onChangeAnswer } = props);
}

let body, feedback, selectedCount;
return <>
{type === 'teacher-preview' &&
<div className="correct-incorrect">
{isCorrect && correctIncorrectIcon}
</div>}
<input
type="radio"
className="answer-input-box"
checked={isSelected}
id={`${qid}-option-${iter}`}
name={`${qid}-options`}
onChange={onChange}
disabled={disabled || !onChangeAnswer}
aria-details={feedbackId}
/>
<label
onKeyPress={onKeyPress}
htmlFor={`${qid}-option-${iter}`}
className="answer-label">
<span
className="answer-letter-wrapper"
aria-label={ariaLabel}
data-answer-choice={ALPHABET[iter]}
data-test-id={`answer-choice-${ALPHABET[iter]}`}
>
</span>
<AnswerAnswer
answer={answer}
contentRenderer={contentRenderer}
show_all_feedback={show_all_feedback}
tableFeedbackEnabled={tableFeedbackEnabled}
isCorrect={isCorrect}
isIncorrect={isIncorrect} />
</label>
</>
}

const AnswerBody = (props: AnswerBodyProps) => {
return props.type === 'teacher-review'
? <TeacherReview {...props} />
: <AnswerChoice {...props} />
}

export const Answer = (props: AnswerProps) => {
const {
type,
answer,
disabled,
answerId,
correctAnswerId,
incorrectAnswerId,
} = props;

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

const isSelected = isChecked || isPreviousResponse;
const classes = cn('answers-answer', {
'disabled': disabled,
'answer-selected': isChecked || isPreviousResponse,
'answer-selected': isSelected,
'answer-correct': isCorrect && type !== 'student-mpp',
'answer-incorrect': incorrectAnswerId && isAnswerIncorrect(answer, incorrectAnswerId),
});

const correctIncorrectIcon = (
<div className="correct-incorrect">
{isCorrect && props.correctIncorrectIcon}
</div>
);

let ariaLabel = `${isChecked ? 'Selected ' : ''}Choice ${ALPHABET[iter]}`;
// somewhat misleading - this means that there is a correct answer,
// not necessarily that this answer is correct
if (hasCorrectAnswer) {
ariaLabel += `(${isCorrect ? 'Correct' : 'Incorrect'} Answer)`;
}
ariaLabel += ':';

let onChangeAnswer: AnswerProps['onChangeAnswer'], radioBox;

const onChange = () => onChangeAnswer && onChangeAnswer(answer);

if (!hasCorrectAnswer
&& (type !== 'teacher-review')
&& (type !== 'teacher-preview')
&& (type !== 'student-mpp')) {
({ onChangeAnswer } = props);
}

if (onChangeAnswer) {
radioBox = (
<input
type="radio"
className="answer-input-box"
checked={isChecked}
id={`${qid}-option-${iter}`}
name={`${qid}-options`}
onChange={onChange}
disabled={disabled}
/>
);
}

if (show_all_feedback && answer.feedback_html && !tableFeedbackEnabled) {
feedback = (
<SimpleFeedback key="question-mc-feedback" contentRenderer={contentRenderer}>
{answer.feedback_html}
</SimpleFeedback>
);
}

if (type === 'teacher-review') {
let percent = 0;
if (answer.selected_count && answered_count) {
percent = Math.round((answer.selected_count / answered_count) * 100);
}
selectedCount = (
<span
className="selected-count"
data-percent={`${percent}`}
>
{answer.selected_count}
</span>
);

body = (
<div className="review-wrapper">
<div className={cn('review-count', { 'green': isCorrect, 'red': !isCorrect })}>
{selectedCount}
<span className={cn('letter', { 'green': isCorrect, 'red': !isCorrect })}>
{ALPHABET[iter]}
</span>
</div>

<div className="answer-answer">
<Content className="answer-content" component={contentRenderer} html={answer.content_html} />
{feedback}
</div>
</div>
);
} else {
body = (
<>
{type === 'teacher-preview' && correctIncorrectIcon}
{selectedCount}
{radioBox}
<label
onKeyPress={onKeyPress}
htmlFor={`${qid}-option-${iter}`}
className="answer-label">
<span className="answer-letter-wrapper">
<button
onClick={onChange}
aria-label={ariaLabel}
className="answer-letter"
disabled={disabled || isIncorrect}
data-test-id={`answer-choice-${ALPHABET[iter]}`}
>
{ALPHABET[iter]}
</button>
</span>
<div className="answer-answer">
<AnswerIndicator isCorrect={isCorrect} isIncorrect={isIncorrect} />
<Content className="answer-content" component={contentRenderer} html={answer.content_html} />
{feedback}
</div>
</label>
</>
);
}

return (
<div className="openstax-answer">
<section className={classes}>
{body}
<AnswerBody
{...props}
isCorrect={isCorrect}
isSelected={isSelected}
isIncorrect={isIncorrect} />
</section>
</div>
);
Expand Down
3 changes: 2 additions & 1 deletion src/components/AnswersTable.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -114,7 +114,8 @@ describe('AnswersTable', () => {
const tree = renderer.create(
<AnswersTable {...props} question={{...props.question, id: ''}} />
);
expect(tree.root.findAllByProps({ qid: 'auto-0' }).length).toBe(2);
// 2 answers * 3 times the prop is passed down (Answer -> AnswerBody -> RadioAnswer)
expect(tree.root.findAllByProps({ qid: 'auto-0' }).length).toBe(6);
});

it('defaults type and show_all_feedback', () => {
Expand Down
23 changes: 15 additions & 8 deletions src/components/AnswersTable.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ export const AnswersTable = (props: AnswersTableProps) => {

const { id } = question;

const feedback: { index: number, html: string }[] = [];
const feedback: { index: number, html: string, id: string }[] = [];

const sortedAnswersByIdOrder = (idOrder: ID[]) => {
const { answers } = question;
Expand Down Expand Up @@ -69,34 +69,41 @@ export const AnswersTable = (props: AnswersTableProps) => {
question_id: typeof question.id === 'string' ? parseInt(question.id, 10) : question.id
},
iter: i,
key: `${questionAnswerProps.qid}-option-${i}`
key: `${questionAnswerProps.qid}-option-${i}`,
};
const answerProps = Object.assign({}, additionalProps, questionAnswerProps);
let html: string | undefined;
let feedbackId: string | undefined;

if (show_all_feedback && answer.feedback_html && tableFeedbackEnabled) {
feedback.push({ index: i, html: answer.feedback_html })
html = answer.feedback_html;
} else if (answer.id === incorrectAnswerId && feedback_html) {
feedback.push({ index: i, html: feedback_html })
html = feedback_html;
} else if (answer.id === correct_answer_id && correct_answer_feedback_html) {
feedback.push({ index: i, html: correct_answer_feedback_html })
html = correct_answer_feedback_html;
}

if (html) {
feedbackId = `feedback-${questionAnswerProps.qid}-${i}`
feedback.push({ index: i, html, id: feedbackId });
}

return (
<Answer {...answerProps} />
<Answer feedbackId={feedbackId} {...answerProps} />
);
});

feedback.forEach((item, i) => {
const spliceIndex = item.index + i + 1;
answersHtml.splice(spliceIndex, 0, (
<Feedback key={spliceIndex} contentRenderer={props.contentRenderer}>
<Feedback id={item.id} key={spliceIndex} contentRenderer={props.contentRenderer}>
{item.html}
</Feedback>
));
});

return (
<div className="answers-table">
<div role="radiogroup" aria-label="Answer choices" className="answers-table">
{instructions}
{answersHtml}
</div>
Expand Down
5 changes: 3 additions & 2 deletions src/components/Feedback.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ interface FeedbackProps {
children: string;
className?: string;
contentRenderer?: JSX.Element;
id: string
}

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

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

return (
<aside className={wrapperClasses}>
<aside id={id} className={wrapperClasses}>
<div className="arrow" aria-label="Answer Feedback" />
<SimpleFeedback {...props}>
{props.children}
Expand Down
Loading

0 comments on commit 86c1455

Please sign in to comment.