Skip to content

Commit ea83293

Browse files
feat(Quiz,QuizQuestion): add action buttons for answer options (#724)
1 parent 2de3256 commit ea83293

File tree

7 files changed

+537
-7
lines changed

7 files changed

+537
-7
lines changed

src/quiz-question/answer.tsx

Lines changed: 39 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,13 @@
11
import React from "react";
22
import { RadioGroup } from "@headlessui/react";
33
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
4-
import { faCheck, faXmark } from "@fortawesome/free-solid-svg-icons";
4+
import {
5+
faCheck,
6+
faXmark,
7+
faMicrophone,
8+
} from "@fortawesome/free-solid-svg-icons";
59

10+
import { Button } from "../button";
611
import { QuizQuestionValidation, type QuizQuestionAnswer } from "./types";
712

813
interface AnswerProps<
@@ -52,11 +57,14 @@ const radioOptionDefaultClasses = [
5257
"p-[20px]",
5358
"flex",
5459
"items-center",
60+
"col-start-1",
61+
"row-start-1",
5562
];
5663

5764
const radioWrapperDefaultClasses = [
58-
"flex",
59-
"flex-col",
65+
"grid",
66+
"grid-cols-1",
67+
"grid-rows-[auto_auto]",
6068
"border-x-4",
6169
"border-t-4",
6270
"last:border-b-4",
@@ -110,10 +118,18 @@ export const Answer = <AnswerT extends number | string>({
110118
checked,
111119
validation,
112120
feedback,
121+
action,
113122
}: AnswerProps<AnswerT>) => {
123+
const labelId = `quiz-answer-${value}-label`;
124+
114125
const getRadioWrapperCls = () => {
115126
const cls = [...radioWrapperDefaultClasses];
116127

128+
// Add second column for action button when action is provided
129+
if (action) {
130+
cls.push("grid-cols-[1fr_auto]", "gap-x-4");
131+
}
132+
117133
if (validation?.state === "correct")
118134
cls.push("border-l-background-success");
119135
if (validation?.state === "incorrect")
@@ -141,16 +157,34 @@ export const Answer = <AnswerT extends number | string>({
141157
{({ active }) => (
142158
<>
143159
<RadioIcon active={active} checked={!!checked} />
144-
<RadioGroup.Label className="m-0 text-foreground-primary overflow-auto">
160+
<RadioGroup.Label
161+
id={labelId}
162+
className="m-0 text-foreground-primary break-words min-w-0"
163+
>
145164
{label}
146165
</RadioGroup.Label>
147166
</>
148167
)}
149168
</RadioGroup.Option>
169+
170+
{action && (
171+
<div className="col-start-2 row-start-1 flex items-center justify-center pe-[20px]">
172+
<Button
173+
onClick={action.onClick}
174+
aria-label={action.ariaLabel}
175+
aria-describedby={labelId}
176+
role="button"
177+
>
178+
<FontAwesomeIcon icon={faMicrophone} />
179+
</Button>
180+
</div>
181+
)}
182+
150183
{(!!validation || !!feedback) && (
151184
// Remove the default bottom margin of the validation message `p`,
152185
// and apply a bottom padding of 20px to match the top padding of RadioGroup.Option
153-
<div className="ps-[20px] pb-[20px] [&>p:last-child]:m-0">
186+
// Span both columns for feedback
187+
<div className="col-span-2 row-start-2 ps-[20px] pb-[20px] [&>p:last-child]:m-0">
154188
{validation && (
155189
<ValidationMessage
156190
state={validation.state}

src/quiz-question/quiz-question.stories.tsx

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -601,4 +601,92 @@ export const WithRubyText: Story = {
601601
},
602602
};
603603

604+
export const WithActionButtons: Story = {
605+
render: QuizQuestionComp,
606+
args: {
607+
question: "Which of the following is the correct greeting?",
608+
answers: [
609+
{
610+
label: "Hello, how are you?",
611+
value: 1,
612+
action: {
613+
onClick: () => alert("Playing audio for: Hello, how are you?"),
614+
ariaLabel: "Practice speaking",
615+
},
616+
},
617+
{
618+
label: "Hi there!",
619+
value: 2,
620+
action: {
621+
onClick: () => alert("Playing audio for: Hi there!"),
622+
ariaLabel: "Practice speaking",
623+
},
624+
},
625+
{
626+
label: "Good morning",
627+
value: 3,
628+
action: {
629+
onClick: () => alert("Playing audio for: Good morning"),
630+
ariaLabel: "Practice speaking",
631+
},
632+
},
633+
{
634+
label: "Hey",
635+
value: 4,
636+
// No action for this answer
637+
},
638+
],
639+
position: 1,
640+
},
641+
parameters: {
642+
docs: {
643+
source: {
644+
code: `const App = () => {
645+
const [answer, setAnswer] = useState();
646+
647+
return (
648+
<QuizQuestion
649+
question="Which of the following is the correct greeting?"
650+
answers={[
651+
{
652+
label: "Hello, how are you?",
653+
value: 1,
654+
action: {
655+
onClick: () => console.log("Open speaking modal"),
656+
ariaLabel: "Practice speaking"
657+
}
658+
},
659+
{
660+
label: "Hi there!",
661+
value: 2,
662+
action: {
663+
onClick: () => console.log("Open speaking modal"),
664+
ariaLabel: "Practice speaking"
665+
}
666+
},
667+
{
668+
label: "Good morning",
669+
value: 3,
670+
action: {
671+
onClick: () => console.log("Open speaking modal"),
672+
ariaLabel: "Practice speaking"
673+
}
674+
},
675+
{
676+
label: "Hey",
677+
value: 4
678+
// No action for this answer
679+
}
680+
]}
681+
onChange={(newAnswer) => setAnswer(newAnswer)}
682+
selectedAnswer={answer}
683+
position={1}
684+
/>
685+
);
686+
}`,
687+
},
688+
},
689+
},
690+
};
691+
604692
export default story;

src/quiz-question/quiz-question.test.tsx

Lines changed: 84 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import React from "react";
2-
import { render, screen, within } from "@testing-library/react";
2+
import { render, screen, within, fireEvent } from "@testing-library/react";
33
import userEvent from "@testing-library/user-event";
44

55
import { QuizQuestion } from "./quiz-question";
@@ -300,6 +300,89 @@ describe("<QuizQuestion />", () => {
300300
within(radioGroup).queryByText("Culpa dolores aut."),
301301
).not.toBeInTheDocument();
302302
});
303+
304+
it("should render action buttons when provided", () => {
305+
const handleAction1 = jest.fn();
306+
const handleAction2 = jest.fn();
307+
308+
render(
309+
<QuizQuestion
310+
question="Lorem ipsum"
311+
answers={[
312+
{
313+
label: "Option 1",
314+
value: 1,
315+
action: {
316+
onClick: handleAction1,
317+
ariaLabel: "Practice speaking option 1",
318+
},
319+
},
320+
{
321+
label: "Option 2",
322+
value: 2,
323+
action: {
324+
onClick: handleAction2,
325+
ariaLabel: "Practice speaking option 2",
326+
},
327+
},
328+
{
329+
label: "Option 3",
330+
value: 3,
331+
// No action for this option
332+
},
333+
]}
334+
/>,
335+
);
336+
337+
const actionButton1 = screen.getByRole("button", {
338+
name: "Practice speaking option 1",
339+
});
340+
const actionButton2 = screen.getByRole("button", {
341+
name: "Practice speaking option 2",
342+
});
343+
344+
expect(actionButton1).toBeInTheDocument();
345+
expect(actionButton2).toBeInTheDocument();
346+
347+
expect(actionButton1).toHaveAttribute(
348+
"aria-describedby",
349+
"quiz-answer-1-label",
350+
);
351+
expect(actionButton2).toHaveAttribute(
352+
"aria-describedby",
353+
"quiz-answer-2-label",
354+
);
355+
356+
fireEvent.click(actionButton1);
357+
expect(handleAction1).toHaveBeenCalledTimes(1);
358+
359+
fireEvent.click(actionButton2);
360+
expect(handleAction2).toHaveBeenCalledTimes(1);
361+
362+
// Verify no action button for option 3
363+
expect(
364+
screen.queryByRole("button", {
365+
name: "Practice speaking option 3",
366+
}),
367+
).not.toBeInTheDocument();
368+
});
369+
370+
it("should not render action buttons when not provided", () => {
371+
render(
372+
<QuizQuestion
373+
question="Lorem ipsum"
374+
answers={[
375+
{ label: "Option 1", value: 1 },
376+
{ label: "Option 2", value: 2 },
377+
{ label: "Option 3", value: 3 },
378+
]}
379+
/>,
380+
);
381+
382+
// Verify no buttons are rendered
383+
const allButtons = screen.queryAllByRole("button", { hidden: true });
384+
expect(allButtons).toHaveLength(0);
385+
});
303386
});
304387

305388
// ------------------------------

src/quiz-question/quiz-question.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,7 @@ export const QuizQuestion = <AnswerT extends number | string>({
6565
<QuestionText question={question} position={position} />
6666
</RadioGroup.Label>
6767

68-
{answers.map(({ value, label, feedback, validation }) => {
68+
{answers.map(({ value, label, feedback, validation, action }) => {
6969
const checked = selectedAnswer === value;
7070
return (
7171
<Answer
@@ -76,6 +76,7 @@ export const QuizQuestion = <AnswerT extends number | string>({
7676
checked={checked}
7777
disabled={disabled}
7878
validation={validation}
79+
action={action}
7980
/>
8081
);
8182
})}

src/quiz-question/types.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,21 @@ export interface QuizQuestionAnswer<T extends number | string> {
99
* Information needed to render the validation status
1010
*/
1111
validation?: QuizQuestionValidation;
12+
13+
/**
14+
* Optional action button configuration.
15+
* When provided, renders an action button next to this answer.
16+
*/
17+
action?: {
18+
/**
19+
* Click handler for the action button
20+
*/
21+
onClick: () => void;
22+
/**
23+
* Accessible label for the action button
24+
*/
25+
ariaLabel: string;
26+
};
1227
}
1328

1429
export interface QuizQuestionValidation {

0 commit comments

Comments
 (0)