Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
44 changes: 39 additions & 5 deletions src/quiz-question/answer.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,13 @@
import React from "react";
import { RadioGroup } from "@headlessui/react";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faCheck, faXmark } from "@fortawesome/free-solid-svg-icons";
import {
faCheck,
faXmark,
faMicrophone,
} from "@fortawesome/free-solid-svg-icons";

import { Button } from "../button";
import { QuizQuestionValidation, type QuizQuestionAnswer } from "./types";

interface AnswerProps<
Expand Down Expand Up @@ -52,11 +57,14 @@ const radioOptionDefaultClasses = [
"p-[20px]",
"flex",
"items-center",
"col-start-1",
"row-start-1",
];

const radioWrapperDefaultClasses = [
"flex",
"flex-col",
"grid",
"grid-cols-1",
"grid-rows-[auto_auto]",
"border-x-4",
"border-t-4",
"last:border-b-4",
Expand Down Expand Up @@ -110,10 +118,18 @@ export const Answer = <AnswerT extends number | string>({
checked,
validation,
feedback,
action,
}: AnswerProps<AnswerT>) => {
const labelId = `quiz-answer-${value}-label`;

const getRadioWrapperCls = () => {
const cls = [...radioWrapperDefaultClasses];

// Add second column for action button when action is provided
if (action) {
cls.push("grid-cols-[1fr_auto]", "gap-x-4");
}

if (validation?.state === "correct")
cls.push("border-l-background-success");
if (validation?.state === "incorrect")
Expand Down Expand Up @@ -141,16 +157,34 @@ export const Answer = <AnswerT extends number | string>({
{({ active }) => (
<>
<RadioIcon active={active} checked={!!checked} />
<RadioGroup.Label className="m-0 text-foreground-primary overflow-auto">
<RadioGroup.Label
id={labelId}
className="m-0 text-foreground-primary break-words min-w-0"
>
{label}
</RadioGroup.Label>
</>
)}
</RadioGroup.Option>

{action && (
<div className="col-start-2 row-start-1 flex items-center justify-center pe-[20px]">
<Button
onClick={action.onClick}
aria-label={action.ariaLabel}
aria-describedby={labelId}
role="button"
>
<FontAwesomeIcon icon={faMicrophone} />
</Button>
</div>
)}

{(!!validation || !!feedback) && (
// Remove the default bottom margin of the validation message `p`,
// and apply a bottom padding of 20px to match the top padding of RadioGroup.Option
<div className="ps-[20px] pb-[20px] [&>p:last-child]:m-0">
// Span both columns for feedback
<div className="col-span-2 row-start-2 ps-[20px] pb-[20px] [&>p:last-child]:m-0">
{validation && (
<ValidationMessage
state={validation.state}
Expand Down
88 changes: 88 additions & 0 deletions src/quiz-question/quiz-question.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -601,4 +601,92 @@ export const WithRubyText: Story = {
},
};

export const WithActionButtons: Story = {
render: QuizQuestionComp,
args: {
question: "Which of the following is the correct greeting?",
answers: [
{
label: "Hello, how are you?",
value: 1,
action: {
onClick: () => alert("Playing audio for: Hello, how are you?"),
ariaLabel: "Practice speaking",
},
},
{
label: "Hi there!",
value: 2,
action: {
onClick: () => alert("Playing audio for: Hi there!"),
ariaLabel: "Practice speaking",
},
},
{
label: "Good morning",
value: 3,
action: {
onClick: () => alert("Playing audio for: Good morning"),
ariaLabel: "Practice speaking",
},
},
{
label: "Hey",
value: 4,
// No action for this answer
},
],
position: 1,
},
parameters: {
docs: {
source: {
code: `const App = () => {
const [answer, setAnswer] = useState();

return (
<QuizQuestion
question="Which of the following is the correct greeting?"
answers={[
{
label: "Hello, how are you?",
value: 1,
action: {
onClick: () => console.log("Open speaking modal"),
ariaLabel: "Practice speaking"
}
},
{
label: "Hi there!",
value: 2,
action: {
onClick: () => console.log("Open speaking modal"),
ariaLabel: "Practice speaking"
}
},
{
label: "Good morning",
value: 3,
action: {
onClick: () => console.log("Open speaking modal"),
ariaLabel: "Practice speaking"
}
},
{
label: "Hey",
value: 4
// No action for this answer
}
]}
onChange={(newAnswer) => setAnswer(newAnswer)}
selectedAnswer={answer}
position={1}
/>
);
}`,
},
},
},
};

export default story;
85 changes: 84 additions & 1 deletion src/quiz-question/quiz-question.test.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import React from "react";
import { render, screen, within } from "@testing-library/react";
import { render, screen, within, fireEvent } from "@testing-library/react";
import userEvent from "@testing-library/user-event";

import { QuizQuestion } from "./quiz-question";
Expand Down Expand Up @@ -300,6 +300,89 @@ describe("<QuizQuestion />", () => {
within(radioGroup).queryByText("Culpa dolores aut."),
).not.toBeInTheDocument();
});

it("should render action buttons when provided", () => {
const handleAction1 = jest.fn();
const handleAction2 = jest.fn();

render(
<QuizQuestion
question="Lorem ipsum"
answers={[
{
label: "Option 1",
value: 1,
action: {
onClick: handleAction1,
ariaLabel: "Practice speaking option 1",
},
},
{
label: "Option 2",
value: 2,
action: {
onClick: handleAction2,
ariaLabel: "Practice speaking option 2",
},
},
{
label: "Option 3",
value: 3,
// No action for this option
},
]}
/>,
);

const actionButton1 = screen.getByRole("button", {
name: "Practice speaking option 1",
});
const actionButton2 = screen.getByRole("button", {
name: "Practice speaking option 2",
});

expect(actionButton1).toBeInTheDocument();
expect(actionButton2).toBeInTheDocument();

expect(actionButton1).toHaveAttribute(
"aria-describedby",
"quiz-answer-1-label",
);
expect(actionButton2).toHaveAttribute(
"aria-describedby",
"quiz-answer-2-label",
);

fireEvent.click(actionButton1);
expect(handleAction1).toHaveBeenCalledTimes(1);

fireEvent.click(actionButton2);
expect(handleAction2).toHaveBeenCalledTimes(1);

// Verify no action button for option 3
expect(
screen.queryByRole("button", {
name: "Practice speaking option 3",
}),
).not.toBeInTheDocument();
});

it("should not render action buttons when not provided", () => {
render(
<QuizQuestion
question="Lorem ipsum"
answers={[
{ label: "Option 1", value: 1 },
{ label: "Option 2", value: 2 },
{ label: "Option 3", value: 3 },
]}
/>,
);

// Verify no buttons are rendered
const allButtons = screen.queryAllByRole("button", { hidden: true });
expect(allButtons).toHaveLength(0);
});
});

// ------------------------------
Expand Down
3 changes: 2 additions & 1 deletion src/quiz-question/quiz-question.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,7 @@ export const QuizQuestion = <AnswerT extends number | string>({
<QuestionText question={question} position={position} />
</RadioGroup.Label>

{answers.map(({ value, label, feedback, validation }) => {
{answers.map(({ value, label, feedback, validation, action }) => {
const checked = selectedAnswer === value;
return (
<Answer
Expand All @@ -76,6 +76,7 @@ export const QuizQuestion = <AnswerT extends number | string>({
checked={checked}
disabled={disabled}
validation={validation}
action={action}
/>
);
})}
Expand Down
15 changes: 15 additions & 0 deletions src/quiz-question/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,21 @@ export interface QuizQuestionAnswer<T extends number | string> {
* Information needed to render the validation status
*/
validation?: QuizQuestionValidation;

/**
* Optional action button configuration.
* When provided, renders an action button next to this answer.
*/
action?: {
/**
* Click handler for the action button
*/
onClick: () => void;
/**
* Accessible label for the action button
*/
ariaLabel: string;
};
}

export interface QuizQuestionValidation {
Expand Down
Loading