diff --git a/src/quiz-question/answer.tsx b/src/quiz-question/answer.tsx index 4b37a688..005a8f53 100644 --- a/src/quiz-question/answer.tsx +++ b/src/quiz-question/answer.tsx @@ -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< @@ -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", @@ -110,10 +118,18 @@ export const Answer = ({ checked, validation, feedback, + action, }: AnswerProps) => { + 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") @@ -141,16 +157,34 @@ export const Answer = ({ {({ active }) => ( <> - + {label} )} + + {action && ( +
+ +
+ )} + {(!!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 -
+ // Span both columns for feedback +
{validation && ( 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 ( + 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; diff --git a/src/quiz-question/quiz-question.test.tsx b/src/quiz-question/quiz-question.test.tsx index 450f1a33..8fcea32e 100644 --- a/src/quiz-question/quiz-question.test.tsx +++ b/src/quiz-question/quiz-question.test.tsx @@ -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"; @@ -300,6 +300,89 @@ describe("", () => { within(radioGroup).queryByText("Culpa dolores aut."), ).not.toBeInTheDocument(); }); + + it("should render action buttons when provided", () => { + const handleAction1 = jest.fn(); + const handleAction2 = jest.fn(); + + render( + , + ); + + 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( + , + ); + + // Verify no buttons are rendered + const allButtons = screen.queryAllByRole("button", { hidden: true }); + expect(allButtons).toHaveLength(0); + }); }); // ------------------------------ diff --git a/src/quiz-question/quiz-question.tsx b/src/quiz-question/quiz-question.tsx index e893e39a..5d321904 100644 --- a/src/quiz-question/quiz-question.tsx +++ b/src/quiz-question/quiz-question.tsx @@ -65,7 +65,7 @@ export const QuizQuestion = ({ - {answers.map(({ value, label, feedback, validation }) => { + {answers.map(({ value, label, feedback, validation, action }) => { const checked = selectedAnswer === value; return ( ({ checked={checked} disabled={disabled} validation={validation} + action={action} /> ); })} diff --git a/src/quiz-question/types.ts b/src/quiz-question/types.ts index 2a022c38..8cfb30a2 100644 --- a/src/quiz-question/types.ts +++ b/src/quiz-question/types.ts @@ -9,6 +9,21 @@ export interface QuizQuestionAnswer { * 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 { diff --git a/src/quiz/quiz.stories.tsx b/src/quiz/quiz.stories.tsx index 706553b9..fce24b74 100644 --- a/src/quiz/quiz.stories.tsx +++ b/src/quiz/quiz.stories.tsx @@ -695,4 +695,222 @@ const App = () => { }, }; +const QuizWithActionButtons = () => { + const initialQuestions: Question[] = [ + { + question: "Which question is incorrect?", + answers: [ + { + label: "Are you into photography?", + value: 1, + action: { + onClick: () => { + alert("Speaking practice: Are you into photography?"); + }, + ariaLabel: "Practice speaking", + }, + }, + { + label: "Are they into photography?", + value: 2, + action: { + onClick: () => { + alert("Speaking practice: Are they into photography?"); + }, + ariaLabel: "Practice speaking", + }, + }, + { + label: "Is he into photography?", + value: 3, + action: { + onClick: () => { + alert("Speaking practice: Is he into photography?"); + }, + ariaLabel: "Practice speaking", + }, + }, + { label: "Am we into photography?", value: 4 }, + ], + correctAnswer: 4, + }, + { + question: "Which adjective shows a negative feeling?", + answers: [ + { + label: "Friendly", + value: 1, + action: { + onClick: () => { + alert("Speaking practice: Friendly"); + }, + ariaLabel: "Practice speaking", + }, + }, + { + label: "Cool", + value: 2, + action: { + onClick: () => { + alert("Speaking practice: Cool"); + }, + ariaLabel: "Practice speaking", + }, + }, + { + label: "Supportive", + value: 3, + action: { + onClick: () => { + alert("Speaking practice: Supportive"); + }, + ariaLabel: "Practice speaking", + }, + }, + { label: "Boring", value: 4 }, + ], + correctAnswer: 4, + }, + { + question: + "What does this sentence mean: `I've played these games before`?", + answers: [ + { label: "You are playing them now.", value: 1 }, + { label: "You will play them later.", value: 2 }, + { label: "You never played them.", value: 3 }, + { label: "You played them in the past.", value: 4 }, + ], + correctAnswer: 4, + }, + ]; + + const { questions } = useQuiz({ + initialQuestions, + validationMessages: { + correct: "Correct.", + incorrect: "Incorrect.", + }, + passingPercent: 100, + }); + + return ; +}; + +export const WithActionButtons: Story = { + render: QuizWithActionButtons, + args: {}, + parameters: { + docs: { + description: { + story: + "Quiz with action buttons on selected answers. Useful for language learning features like 'Practice speaking'. Some answers have action buttons while others do not, demonstrating mixed configurations across questions.", + }, + source: { + code: ` +import { Quiz, useQuiz } from '@freecodecamp/ui'; + +const initialQuestions = [ + { + question: "Which question is incorrect?", + answers: [ + { + label: "Are you into photography?", + value: 1, + action: { + onClick: () => { + alert("Speaking practice: Are you into photography?"); + }, + ariaLabel: "Practice speaking", + }, + }, + { + label: "Are they into photography?", + value: 2, + action: { + onClick: () => { + alert("Speaking practice: Are they into photography?"); + }, + ariaLabel: "Practice speaking", + }, + }, + { + label: "Is he into photography?", + value: 3, + action: { + onClick: () => { + alert("Speaking practice: Is he into photography?"); + }, + ariaLabel: "Practice speaking", + }, + }, + { label: "Am we into photography?", value: 4 }, + ], + correctAnswer: 4, + }, + { + question: "Which adjective shows a negative feeling?", + answers: [ + { + label: "Friendly", + value: 1, + action: { + onClick: () => { + alert("Speaking practice: Friendly"); + }, + ariaLabel: "Practice speaking", + }, + }, + { + label: "Cool", + value: 2, + action: { + onClick: () => { + alert("Speaking practice: Cool"); + }, + ariaLabel: "Practice speaking", + }, + }, + { + label: "Supportive", + value: 3, + action: { + onClick: () => { + alert("Speaking practice: Supportive"); + }, + ariaLabel: "Practice speaking", + }, + }, + { label: "Boring", value: 4 }, + ], + correctAnswer: 4, + }, + { + question: "What does this sentence mean: \`I've played these games before\`?", + answers: [ + { label: "You are playing them now.", value: 1 }, + { label: "You will play them later.", value: 2 }, + { label: "You never played them.", value: 3 }, + { label: "You played them in the past.", value: 4 }, + ], + correctAnswer: 4, + }, +]; + +const App = () => { + const { questions } = useQuiz({ + initialQuestions, + validationMessages: { + correct: "Correct.", + incorrect: "Incorrect.", + }, + passingPercent: 100, + }); + + return ; +};`, + }, + }, + }, +}; + export default story; diff --git a/src/quiz/quiz.test.tsx b/src/quiz/quiz.test.tsx index 96839035..896f382e 100644 --- a/src/quiz/quiz.test.tsx +++ b/src/quiz/quiz.test.tsx @@ -123,6 +123,97 @@ describe("", () => { expect(question).toBeRequired(); }); }); + + it("should render action buttons when answers have action configuration", async () => { + const actionHandlers = { + q1a1: jest.fn(), + q1a2: jest.fn(), + q2a1: jest.fn(), + }; + + const questions = [ + { + question: "Question 1", + answers: [ + { + label: "Answer 1", + value: 1, + action: { + onClick: actionHandlers.q1a1, + ariaLabel: "Practice Answer 1", + }, + }, + { + label: "Answer 2", + value: 2, + action: { + onClick: actionHandlers.q1a2, + ariaLabel: "Practice Answer 2", + }, + }, + { label: "Answer 3", value: 3 }, + ], + correctAnswer: 1, + }, + { + question: "Question 2", + answers: [ + { + label: "Answer A", + value: 1, + action: { + onClick: actionHandlers.q2a1, + ariaLabel: "Practice Answer A", + }, + }, + { label: "Answer B", value: 2 }, + ], + correctAnswer: 1, + }, + ]; + + render(); + + const question1Button1 = screen.getByRole("button", { + name: "Practice Answer 1", + }); + const question1Button2 = screen.getByRole("button", { + name: "Practice Answer 2", + }); + const question2Button1 = screen.getByRole("button", { + name: "Practice Answer A", + }); + + expect(question1Button1).toBeInTheDocument(); + expect(question1Button2).toBeInTheDocument(); + expect(question2Button1).toBeInTheDocument(); + + await userEvent.click(question1Button1); + await userEvent.click(question1Button2); + await userEvent.click(question2Button1); + + expect(actionHandlers.q1a1).toHaveBeenCalledTimes(1); + expect(actionHandlers.q1a2).toHaveBeenCalledTimes(1); + expect(actionHandlers.q2a1).toHaveBeenCalledTimes(1); + }); + + it("should not render action buttons when answers do not have action configuration", () => { + const questions = [ + { + question: "Question 1", + answers: [ + { label: "Answer 1", value: 1 }, + { label: "Answer 2", value: 2 }, + ], + correctAnswer: 1, + }, + ]; + + render(); + + const buttons = screen.queryAllByRole("button"); + expect(buttons).toHaveLength(0); + }); }); // ------------------------------