From f6e4231c5d5355cde3e305e800dfb3f565a6c079 Mon Sep 17 00:00:00 2001
From: ConsultAdd
Date: Sun, 25 Oct 2026 23:12:29 +0530
Subject: [PATCH 1/2] feat(ui): add yellow75 color token
---
src/colors.css | 1 +
tailwind.config.js | 1 +
2 files changed, 2 insertions(+)
diff --git a/src/colors.css b/src/colors.css
index 34c6dd0f..0b5cfaa7 100644
--- a/src/colors.css
+++ b/src/colors.css
@@ -21,6 +21,7 @@
--yellow45: #ffbf00;
--yellow50: #f1be32;
--yellow70: #8a6d3b;
+ --yellow75: #7b5e2c;
--yellow80: #66512c;
--yellow90: #4d3800;
diff --git a/tailwind.config.js b/tailwind.config.js
index bb5b2b45..9310881f 100644
--- a/tailwind.config.js
+++ b/tailwind.config.js
@@ -72,6 +72,7 @@ module.exports = {
450: "var(--yellow45)",
500: "var(--yellow50)",
700: "var(--yellow70)",
+ 750: "var(--yellow75)",
800: "var(--yellow80)",
900: "var(--yellow90)",
},
From 5e5cbd3ac43cfbb6ecdebecae02a0bef7de14bbd Mon Sep 17 00:00:00 2001
From: ConsultAdd
Date: Mon, 26 Oct 2026 01:41:05 +0530
Subject: [PATCH 2/2] feat: add multiple-answer quiz questions with partial
credit scoring
---
src/index.ts | 74 ++-
src/multiple-answer-quiz-question/answer.tsx | 109 ++++
src/multiple-answer-quiz-question/index.ts | 2 +
.../multiple-answer-quiz-question.stories.tsx | 58 ++
.../multiple-answer-quiz-question.test.tsx | 128 +++++
.../multiple-answer-quiz-question.tsx | 92 +++
src/multiple-answer-quiz-question/types.ts | 35 ++
src/quiz/index.ts | 8 +
src/quiz/quiz.test.tsx | 535 ++----------------
src/quiz/quiz.tsx | 50 +-
src/quiz/types.ts | 105 ++--
src/quiz/use-quiz.test.ts | 385 +++----------
src/quiz/use-quiz.ts | 162 ++++--
13 files changed, 798 insertions(+), 945 deletions(-)
create mode 100644 src/multiple-answer-quiz-question/answer.tsx
create mode 100644 src/multiple-answer-quiz-question/index.ts
create mode 100644 src/multiple-answer-quiz-question/multiple-answer-quiz-question.stories.tsx
create mode 100644 src/multiple-answer-quiz-question/multiple-answer-quiz-question.test.tsx
create mode 100644 src/multiple-answer-quiz-question/multiple-answer-quiz-question.tsx
create mode 100644 src/multiple-answer-quiz-question/types.ts
diff --git a/src/index.ts b/src/index.ts
index c0626c79..153b08b8 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -1,31 +1,49 @@
-// Use this file as the entry point for component export
-export { Alert, type AlertProps } from "./alert";
-export { Button, type ButtonProps } from "./button";
-export { Callout } from "./callout";
-export { CloseButton } from "./close-button";
-export { Col } from "./col";
-export { Container } from "./container";
-export { ControlLabel } from "./control-label";
-export { Dropdown } from "./drop-down";
-export { FormControl } from "./form-control";
-export { FormGroup, type FormGroupProps } from "./form-group";
+// // Use this file as the entry point for component export
+// export { Alert, type AlertProps } from "./alert";
+// export { Button, type ButtonProps } from "./button";
+// export { Callout } from "./callout";
+// export { CloseButton } from "./close-button";
+// export { Col } from "./col";
+// export { Container } from "./container";
+// export { ControlLabel } from "./control-label";
+// export { Dropdown } from "./drop-down";
+// export { FormControl } from "./form-control";
+// export { FormGroup, type FormGroupProps } from "./form-group";
+// export {
+// HeadlessDisclosure,
+// type DisclosureProps,
+// type DisclosureButtonProps,
+// type DisclosurePanelProps,
+// } from "./headless-disclosure";
+// export { HelpBlock } from "./help-block";
+// export { Image } from "./image";
+// export { MenuItem } from "./drop-down/menu-item";
+// export { Modal, type ModalProps, type HeaderProps } from "./modal";
+// export { Panel } from "./panel";
+// export { PrismFormatted } from "./prism-formatted";
+// export { Row } from "./row";
+// export { Spacer } from "./spacer";
+// export { Table } from "./table";
+// export { Tabs, TabsList, TabsTrigger, TabsContent } from "./tabs";
+// export { Quiz, useQuiz, type UseQuizResult, type UseQuizProps } from "./quiz";
+// export { QuizQuestion } from "./quiz-question";
+
+// export { getThemingClass } from "./utils";
+
export {
- HeadlessDisclosure,
- type DisclosureProps,
- type DisclosureButtonProps,
- type DisclosurePanelProps,
-} from "./headless-disclosure";
-export { HelpBlock } from "./help-block";
-export { Image } from "./image";
-export { MenuItem } from "./drop-down/menu-item";
-export { Modal, type ModalProps, type HeaderProps } from "./modal";
-export { Panel } from "./panel";
-export { PrismFormatted } from "./prism-formatted";
-export { Row } from "./row";
-export { Spacer } from "./spacer";
-export { Table } from "./table";
-export { Tabs, TabsList, TabsTrigger, TabsContent } from "./tabs";
-export { Quiz, useQuiz, type UseQuizResult, type UseQuizProps } from "./quiz";
+ Quiz,
+ useQuiz,
+ type UseQuizResult,
+ type UseQuizProps,
+ type Question,
+ type SingleAnswerQuestion,
+ type MultipleAnswerQuestion,
+ type QuizProps,
+} from "./quiz";
+
export { QuizQuestion } from "./quiz-question";
-export { getThemingClass } from "./utils";
+export {
+ MultipleAnswerQuizQuestion,
+ type MultipleAnswerQuizQuestionProps,
+} from "./multiple-answer-quiz-question";
diff --git a/src/multiple-answer-quiz-question/answer.tsx b/src/multiple-answer-quiz-question/answer.tsx
new file mode 100644
index 00000000..9f0a8329
--- /dev/null
+++ b/src/multiple-answer-quiz-question/answer.tsx
@@ -0,0 +1,109 @@
+import React, { useId } from "react";
+import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
+import { faCheck, faXmark } from "@fortawesome/free-solid-svg-icons";
+
+import type {
+ QuizQuestionAnswer,
+ QuizQuestionValidation,
+} from "../quiz-question";
+
+interface MultipleAnswerProps<
+ AnswerT extends number | string,
+> extends QuizQuestionAnswer {
+ checked: boolean;
+ disabled?: boolean;
+ required?: boolean;
+ onChange: (checked: boolean) => void;
+}
+
+const ValidationMessage = ({ state, message }: QuizQuestionValidation) => (
+
+
+ {message}
+
+);
+
+export const MultipleAnswer = ({
+ value,
+ label,
+ checked,
+ disabled,
+ required,
+ onChange,
+ validation,
+ feedback,
+ action,
+}: MultipleAnswerProps) => {
+ const id = useId();
+ const labelId = `${id}-label`;
+
+ return (
+
+
+ onChange(event.currentTarget.checked)}
+ className="me-[15px] h-5 w-5 shrink-0 accent-current"
+ />
+
+
+ {label}
+
+
+ {action && (
+
+ Listen
+
+ )}
+
+
+ {(validation || (checked && feedback)) && (
+
+ {validation && (
+
+ )}
+
+ {checked && feedback &&
{feedback}
}
+
+ )}
+
+ );
+};
diff --git a/src/multiple-answer-quiz-question/index.ts b/src/multiple-answer-quiz-question/index.ts
new file mode 100644
index 00000000..50945823
--- /dev/null
+++ b/src/multiple-answer-quiz-question/index.ts
@@ -0,0 +1,2 @@
+export { MultipleAnswerQuizQuestion } from "./multiple-answer-quiz-question";
+export type { MultipleAnswerQuizQuestionProps } from "./types";
diff --git a/src/multiple-answer-quiz-question/multiple-answer-quiz-question.stories.tsx b/src/multiple-answer-quiz-question/multiple-answer-quiz-question.stories.tsx
new file mode 100644
index 00000000..04441e46
--- /dev/null
+++ b/src/multiple-answer-quiz-question/multiple-answer-quiz-question.stories.tsx
@@ -0,0 +1,58 @@
+import React, { useState } from "react";
+import type { Meta, StoryObj } from "@storybook/react";
+
+import { MultipleAnswerQuizQuestion } from "./multiple-answer-quiz-question";
+
+const meta: Meta = {
+ title: "Components/MultipleAnswerQuizQuestion",
+ component: MultipleAnswerQuizQuestion,
+};
+
+export default meta;
+
+type Story = StoryObj;
+
+const InteractiveExample = () => {
+ const [selectedAnswers, setSelectedAnswers] = useState([]);
+
+ return (
+
+
+
+
+ Selected answers: {selectedAnswers.join(", ") || "None"}
+
+
+ );
+};
+
+export const Default: Story = {
+ render: () => ,
+};
+
+export const Disabled: Story = {
+ render: () => (
+
+
+
+ ),
+};
diff --git a/src/multiple-answer-quiz-question/multiple-answer-quiz-question.test.tsx b/src/multiple-answer-quiz-question/multiple-answer-quiz-question.test.tsx
new file mode 100644
index 00000000..88946883
--- /dev/null
+++ b/src/multiple-answer-quiz-question/multiple-answer-quiz-question.test.tsx
@@ -0,0 +1,128 @@
+import React from "react";
+import { render, screen } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import { vi } from "vitest";
+
+import { MultipleAnswerQuizQuestion } from "./multiple-answer-quiz-question";
+
+const answers = [
+ { label: "Option A", value: "A" },
+ { label: "Option B", value: "B" },
+ { label: "Option C", value: "C" },
+];
+
+describe(" ", () => {
+ it("renders the question and checkbox answers", () => {
+ render(
+ ,
+ );
+
+ expect(
+ screen.getByRole("group", {
+ name: "Select all correct answers",
+ }),
+ ).toBeInTheDocument();
+
+ expect(
+ screen.getByRole("checkbox", { name: "Option A" }),
+ ).toBeInTheDocument();
+ expect(
+ screen.getByRole("checkbox", { name: "Option B" }),
+ ).toBeInTheDocument();
+ expect(
+ screen.getByRole("checkbox", { name: "Option C" }),
+ ).toBeInTheDocument();
+ });
+
+ it("marks the supplied selected answers as checked", () => {
+ render(
+ ,
+ );
+
+ expect(screen.getByRole("checkbox", { name: "Option A" })).toBeChecked();
+ expect(
+ screen.getByRole("checkbox", { name: "Option B" }),
+ ).not.toBeChecked();
+ expect(screen.getByRole("checkbox", { name: "Option C" })).toBeChecked();
+ });
+
+ it("calls onChange with the added answer", async () => {
+ const user = userEvent.setup();
+ const onChange = vi.fn();
+
+ render(
+ ,
+ );
+
+ await user.click(screen.getByRole("checkbox", { name: "Option B" }));
+
+ expect(onChange).toHaveBeenCalledWith(["A", "B"]);
+ });
+
+ it("calls onChange with the removed answer excluded", async () => {
+ const user = userEvent.setup();
+ const onChange = vi.fn();
+
+ render(
+ ,
+ );
+
+ await user.click(screen.getByRole("checkbox", { name: "Option A" }));
+
+ expect(onChange).toHaveBeenCalledWith(["B"]);
+ });
+
+ it("does not allow selection when disabled", async () => {
+ const user = userEvent.setup();
+ const onChange = vi.fn();
+
+ render(
+ ,
+ );
+
+ const optionA = screen.getByRole("checkbox", { name: "Option A" });
+
+ expect(optionA).toBeDisabled();
+
+ await user.click(optionA);
+ expect(onChange).not.toHaveBeenCalled();
+ });
+
+ it("renders the question position", () => {
+ render(
+ ,
+ );
+
+ expect(
+ screen.getByRole("group", {
+ name: "2. Select all correct answers",
+ }),
+ ).toBeInTheDocument();
+ });
+});
diff --git a/src/multiple-answer-quiz-question/multiple-answer-quiz-question.tsx b/src/multiple-answer-quiz-question/multiple-answer-quiz-question.tsx
new file mode 100644
index 00000000..9d087025
--- /dev/null
+++ b/src/multiple-answer-quiz-question/multiple-answer-quiz-question.tsx
@@ -0,0 +1,92 @@
+import React from "react";
+
+import type { QuizQuestionAnswer } from "../quiz-question";
+import { Audio } from "../quiz-question/audio";
+import { Transcript } from "../quiz-question/transcript";
+import { MultipleAnswer } from "./answer";
+import type { MultipleAnswerQuizQuestionProps } from "./types";
+
+const QuestionText = ({
+ question,
+ position,
+}: {
+ question: React.ReactNode;
+ position?: number;
+}) => (
+
+ {position == null ? null : `${position}. `}
+ {question}
+
+);
+
+/**
+ * MultipleAnswerQuizQuestion lets users select more than one answer.
+ * Selection state is controlled by the parent through selectedAnswers and onChange.
+ */
+export const MultipleAnswerQuizQuestion = ({
+ question,
+ answers,
+ position,
+ required,
+ disabled,
+ selectedAnswers = [],
+ onChange,
+ audioUrl,
+ audioAriaLabel,
+ transcript,
+ audioStartTime,
+ audioFinishTime,
+}: MultipleAnswerQuizQuestionProps) => {
+ const handleChange = (
+ value: QuizQuestionAnswer["value"],
+ checked: boolean,
+ ) => {
+ if (!onChange) {
+ return;
+ }
+
+ const nextAnswers = checked
+ ? [...selectedAnswers, value]
+ : selectedAnswers.filter((selected) => selected !== value);
+
+ onChange(nextAnswers);
+ };
+
+ return (
+
+
+
+
+
+ {audioUrl && (
+
+ )}
+
+ {answers.map(({ value, label, feedback, validation, action }) => (
+ handleChange(value, checked)}
+ />
+ ))}
+
+ );
+};
diff --git a/src/multiple-answer-quiz-question/types.ts b/src/multiple-answer-quiz-question/types.ts
new file mode 100644
index 00000000..8f806bb5
--- /dev/null
+++ b/src/multiple-answer-quiz-question/types.ts
@@ -0,0 +1,35 @@
+import type { ReactNode } from "react";
+
+import type { QuizQuestionAnswer } from "../quiz-question";
+
+interface MultipleAnswerQuizQuestionBaseProps {
+ question: ReactNode;
+ answers: QuizQuestionAnswer[];
+ position?: number;
+ required?: boolean;
+ disabled?: boolean;
+ selectedAnswers?: AnswerT[];
+ onChange?: (selectedAnswers: AnswerT[]) => void;
+}
+
+type MultipleAnswerQuizQuestionWithoutAudio =
+ MultipleAnswerQuizQuestionBaseProps & {
+ audioUrl?: never;
+ audioAriaLabel?: never;
+ transcript?: never;
+ audioStartTime?: never;
+ audioFinishTime?: never;
+ };
+
+type MultipleAnswerQuizQuestionWithAudio =
+ MultipleAnswerQuizQuestionBaseProps & {
+ audioUrl: string;
+ audioAriaLabel: string;
+ transcript: string;
+ audioStartTime?: number;
+ audioFinishTime?: number;
+ };
+
+export type MultipleAnswerQuizQuestionProps =
+ | MultipleAnswerQuizQuestionWithoutAudio
+ | MultipleAnswerQuizQuestionWithAudio;
diff --git a/src/quiz/index.ts b/src/quiz/index.ts
index f3f26f37..37c15a07 100644
--- a/src/quiz/index.ts
+++ b/src/quiz/index.ts
@@ -1,2 +1,10 @@
export { Quiz } from "./quiz";
+
export { useQuiz, type UseQuizResult, type UseQuizProps } from "./use-quiz";
+
+export type {
+ Question,
+ SingleAnswerQuestion,
+ MultipleAnswerQuestion,
+ QuizProps,
+} from "./types";
diff --git a/src/quiz/quiz.test.tsx b/src/quiz/quiz.test.tsx
index f649234b..f02c3718 100644
--- a/src/quiz/quiz.test.tsx
+++ b/src/quiz/quiz.test.tsx
@@ -1,512 +1,83 @@
import React from "react";
-import { render, screen, within } from "@testing-library/react";
+import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
-import { vi } from "vitest";
+import { describe, expect, it } from "vitest";
import { Quiz } from "./quiz";
-import { type QuizProps } from "./types";
import { useQuiz } from "./use-quiz";
-const ControlledQuiz = ({ disabled, required }: Partial>) => {
- const { questions } = useQuiz({
- initialQuestions: [
- {
- question: "Lorem ipsum dolor sit amet",
- answers: [
- { label: "Option 1", value: 1 },
- { label: "Option 2", value: 2 },
- { label: "Option 3", value: 3 },
- ],
- correctAnswer: 1,
- },
- {
- question: "Consectetur adipiscing elit",
- answers: [
- { label: "Option 1", value: 1 },
- { label: "Option 2", value: 2 },
- { label: "Option 3", value: 3 },
- ],
- correctAnswer: 2,
- },
- {
- question: "Fugit itaque delectus voluptatem alias aliquid",
- answers: [
- { label: "Option 1", value: 1 },
- { label: "Option 2", value: 2 },
- { label: "Option 3", value: 3 },
- ],
- correctAnswer: 3,
- },
- ],
- validationMessages: {
- correct: "Correct",
- incorrect: "Incorrect",
- },
- passingPercent: 80,
- });
-
- return ;
-};
-
describe(" ", () => {
- it("should render as list", () => {
- render( );
-
- expect(screen.getByRole("list")).toBeInTheDocument();
- expect(screen.getAllByRole("listitem")).toHaveLength(3);
- });
-
- it("should render the questions with their position correctly", () => {
- render( );
+ it("should render multiple-answer questions with checkboxes", () => {
+ render(
+ ,
+ );
expect(
- screen.getByRole("radiogroup", {
- name: "1. Lorem ipsum dolor sit amet",
+ screen.getByRole("group", {
+ name: "1. Select all correct answers",
}),
).toBeInTheDocument();
expect(
- screen.getByRole("radiogroup", {
- name: "2. Consectetur adipiscing elit",
- }),
+ screen.getByRole("checkbox", { name: "Option A" }),
).toBeInTheDocument();
expect(
- screen.getByRole("radiogroup", {
- name: "3. Fugit itaque delectus voluptatem alias aliquid",
- }),
+ screen.getByRole("checkbox", { name: "Option B" }),
).toBeInTheDocument();
});
- it("should reflect the selected answers correctly", async () => {
- render( );
-
- const question1 = screen.getByRole("radiogroup", {
- name: "1. Lorem ipsum dolor sit amet",
- });
- const question1Option = within(question1).getByRole("radio", {
- name: "Option 1",
- });
-
- const question2 = screen.getByRole("radiogroup", {
- name: "2. Consectetur adipiscing elit",
- });
- const question2Option = within(question2).getByRole("radio", {
- name: "Option 3",
- });
-
- expect(question1Option).not.toBeChecked();
- expect(question2Option).not.toBeChecked();
-
- await userEvent.click(question1Option);
- await userEvent.click(question2Option);
-
- expect(question1Option).toBeChecked();
- expect(question2Option).toBeChecked();
- });
-
- it("should mark all questions as disabled if `disabled` is `true`", () => {
- render( );
-
- const answers = screen.getAllByRole("radio");
+ it("should reflect selected multiple answers correctly", async () => {
+ const user = userEvent.setup();
- answers.forEach((answer) => {
- expect(answer).toHaveAttribute("aria-disabled", "true");
- });
- });
-
- it("should mark all questions as required if `required` is `true`", () => {
- render( );
-
- const questions = screen.getAllByRole("radiogroup");
-
- questions.forEach((question) => {
- expect(question).toBeRequired();
- });
- });
-
- it("should render action buttons when answers have action configuration", async () => {
- const actionHandlers = {
- q1a1: vi.fn(),
- q1a2: vi.fn(),
- q2a1: vi.fn(),
- };
-
- const questions = [
- {
- question: "Question 1",
- answers: [
- {
- label: "Answer 1",
- value: 1,
- action: {
- onClick: actionHandlers.q1a1,
- ariaLabel: "Practice Answer 1",
- },
- },
+ const ControlledMultipleQuiz = () => {
+ const { questions } = useQuiz({
+ initialQuestions: [
{
- label: "Answer 2",
- value: 2,
- action: {
- onClick: actionHandlers.q1a2,
- ariaLabel: "Practice Answer 2",
- },
+ type: "multiple",
+ question: "Select all correct answers",
+ answers: [
+ { label: "Option A", value: "A" },
+ { label: "Option B", value: "B" },
+ { label: "Option C", value: "C" },
+ ],
+ correctAnswers: ["A", "B"],
},
- { 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 have unique action button aria-describedby IDs across questions with the same answer values", () => {
- const sharedAnswers = [
- {
- label: "Option 1",
- value: 1,
- action: { onClick: vi.fn(), ariaLabel: "Speak option 1" },
- },
- {
- label: "Option 2",
- value: 2,
- action: { onClick: vi.fn(), ariaLabel: "Speak option 2" },
- },
- ];
-
- render(
- ,
- );
-
- const question1 = screen.getByRole("radiogroup", {
- name: "1. Question 1",
- });
- const question2 = screen.getByRole("radiogroup", {
- name: "2. Question 2",
- });
-
- const speak1A = within(question1).getByRole("button", {
- name: "Speak option 1",
- });
- const speak1B = within(question1).getByRole("button", {
- name: "Speak option 2",
- });
- const speak2A = within(question2).getByRole("button", {
- name: "Speak option 1",
- });
- const speak2B = within(question2).getByRole("button", {
- name: "Speak option 2",
- });
-
- const id1A = speak1A.getAttribute("aria-describedby")!;
- const id1B = speak1B.getAttribute("aria-describedby")!;
- const id2A = speak2A.getAttribute("aria-describedby")!;
- const id2B = speak2B.getAttribute("aria-describedby")!;
-
- expect(new Set([id1A, id1B, id2A, id2B]).size).toBe(4);
-
- expect(within(question1).getByText("Option 1").id).toBe(id1A);
- expect(within(question1).getByText("Option 2").id).toBe(id1B);
- expect(within(question2).getByText("Option 1").id).toBe(id2A);
- expect(within(question2).getByText("Option 2").id).toBe(id2B);
- });
-
- 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);
- });
-
- it("should render audio and transcript when question has audioUrl", () => {
- const questions = [
- {
- question: "Question with audio",
- answers: [
- { label: "Answer 1", value: 1 },
- { label: "Answer 2", value: 2 },
- ],
- correctAnswer: 1,
- audioUrl: "test-audio.mp3",
- audioAriaLabel: "Audio for question",
- transcript: "Test transcript
",
- },
- ];
-
- render( );
+ validationMessages: {
+ correct: "Correct",
+ incorrect: "Incorrect",
+ },
+ passingPercent: 80,
+ });
+
+ return ;
+ };
- const audio = screen.getByLabelText("Audio for question");
- expect(audio).toBeInTheDocument();
- expect(audio.tagName).toBe("AUDIO");
- expect(audio).toHaveAttribute("src", "test-audio.mp3");
+ render( );
- const transcript = screen.getByText("Transcript");
- expect(transcript).toBeInTheDocument();
- });
+ const optionA = screen.getByRole("checkbox", { name: "Option A" });
+ const optionB = screen.getByRole("checkbox", { name: "Option B" });
- it("should pass audioStartTime and audioFinishTime to questions with audio segments", () => {
- const questions = [
- {
- question: "Question with audio segment",
- answers: [
- { label: "Answer 1", value: 1 },
- { label: "Answer 2", value: 2 },
- ],
- correctAnswer: 1,
- audioUrl: "test-audio.mp3",
- audioAriaLabel: "Audio segment for question",
- transcript: "Test transcript
",
- audioStartTime: 3,
- audioFinishTime: 8,
- },
- ];
+ expect(optionA).not.toBeChecked();
+ expect(optionB).not.toBeChecked();
- render( );
+ await user.click(optionA);
+ await user.click(optionB);
- const audio = screen.getByLabelText("Audio segment for question");
- expect(audio).toBeInTheDocument();
- expect(audio).toHaveAttribute("src", "test-audio.mp3#t=3");
+ expect(optionA).toBeChecked();
+ expect(optionB).toBeChecked();
});
});
-
-// ------------------------------
-// Type tests
-// ------------------------------
-// Quiz without explicit type
- ;
-
-// Quiz with `number` type
-
- questions={[
- {
- question: "Lorem ipsum dolor sit amet",
- answers: [
- // @ts-expect-error - `value` type must be in accordance with the specified type
- { label: "Option 1", value: "1" },
- { label: "Option 2", value: 2 },
- { label: "Option 3", value: 3 },
- ],
- correctAnswer: 1,
- },
- ]}
-/>;
-
-// Quiz with `string` type
-
- questions={[
- {
- question: "Lorem ipsum dolor sit amet",
- answers: [
- // @ts-expect-error - `value` type must be in accordance with the specified type
- { label: "Option 1", value: 1 },
- { label: "Option 2", value: "2" },
- { label: "Option 3", value: "3" },
- ],
- },
- ]}
-/>;
-
-// Quiz with `value` as number and `selectedAnswer` as string
-
- questions={[
- {
- question: "Lorem ipsum dolor sit amet",
- answers: [
- { label: "Option 1", value: 1 },
- { label: "Option 2", value: 2 },
- { label: "Option 3", value: 3 },
- ],
- // @ts-expect-error - `value` and `selectedAnswer` must have the same type
- selectedAnswer: "1",
- },
- ]}
-/>;
-
-// Quiz with `value` as string and `selectedAnswer` as number
-
- questions={[
- {
- question: "Lorem ipsum dolor sit amet",
- answers: [
- { label: "Option 1", value: "1" },
- { label: "Option 2", value: "2" },
- { label: "Option 3", value: "3" },
- ],
- // @ts-expect-error - `value` and `selectedAnswer` must have the same type
- selectedAnswer: 1,
- },
- ]}
-/>;
-
-// Quiz with `value` as number and `correctAnswer` as string
-
- questions={[
- {
- question: "Lorem ipsum dolor sit amet",
- answers: [
- { label: "Option 1", value: 1 },
- { label: "Option 2", value: 2 },
- { label: "Option 3", value: 3 },
- ],
- // @ts-expect-error - `value` and `selectedAnswer` must have the same type
- correctAnswer: "1",
- },
- ]}
-/>;
-
-// Quiz with `value` as string and `correctAnswer` as number
-
- questions={[
- {
- question: "Lorem ipsum dolor sit amet",
- answers: [
- { label: "Option 1", value: "1" },
- { label: "Option 2", value: "2" },
- { label: "Option 3", value: "3" },
- ],
- // @ts-expect-error - `value` and `selectedAnswer` must have the same type
- correctAnswer: 1,
- },
- ]}
-/>;
-
-// Type tests for audio segment props in Quiz
-Transcript
",
- audioStartTime: 0,
- audioFinishTime: 5,
- },
- ]}
-/>;
-
-// Should error if audioStartTime is present without audioUrl
- ;
-
-// Should error if audioFinishTime is present without audioUrl
- ;
-
-// Should error if audioStartTime is not a number
-Transcript",
- // @ts-expect-error audioStartTime must be a number
- audioStartTime: "0",
- },
- ]}
-/>;
-
-// Should error if audioFinishTime is not a number
-Transcript",
- // @ts-expect-error audioFinishTime must be a number
- audioFinishTime: "5",
- },
- ]}
-/>;
diff --git a/src/quiz/quiz.tsx b/src/quiz/quiz.tsx
index dda9f497..6e5be5d3 100644
--- a/src/quiz/quiz.tsx
+++ b/src/quiz/quiz.tsx
@@ -1,5 +1,6 @@
import React from "react";
+import { MultipleAnswerQuizQuestion } from "../multiple-answer-quiz-question";
import { QuizQuestion } from "../quiz-question";
import { type QuizProps } from "./types";
@@ -7,19 +8,48 @@ export const Quiz = ({
questions,
disabled,
required,
-}: QuizProps) => {
- return (
-
- {questions.map((question, index) => (
-
+}: QuizProps) => (
+
+ {questions.map((question, index) => (
+
+ {question.type === "multiple" ? (
+ question.audioUrl &&
+ question.audioAriaLabel &&
+ question.transcript ? (
+
+ ) : (
+
+ )
+ ) : (
-
- ))}
-
- );
-};
+ )}
+
+ ))}
+
+);
diff --git a/src/quiz/types.ts b/src/quiz/types.ts
index 2db09ddb..189f9f1a 100644
--- a/src/quiz/types.ts
+++ b/src/quiz/types.ts
@@ -1,4 +1,5 @@
import type { QuizQuestionAnswer, QuizQuestionProps } from "../quiz-question";
+import type { MultipleAnswerQuizQuestionProps } from "../multiple-answer-quiz-question";
export interface QuizProps {
questions: Question[];
@@ -6,72 +7,62 @@ export interface QuizProps {
required?: boolean;
}
-// This interface is a subset of QuizQuestionProps.
-// The props are limited to ensure that
-// their configurations don't collide with Quiz'.
-// For example: Quiz should be able to apply `disabled` to all questions
-// without being overriden by the `disabled` prop of the individual question.
interface QuestionBase {
- /**
- * Question text, can be plain text or contain code.
- * If the question text contains code, use the PrismFormatted component to ensure the code is rendered correctly.
- */
question: QuizQuestionProps["question"];
-
- /**
- * Answer options
- */
answers: QuizQuestionAnswer[];
+}
- /**
- * Value of the correct answer
- */
+interface SingleAnswerQuestionBase<
+ AnswerT extends number | string,
+> extends QuestionBase {
+ type?: "single";
correctAnswer: AnswerT;
-
- /**
- * Change event handler, called when an answer is selected
- */
onChange?: (selectedAnswer: AnswerT) => void;
-
- /**
- * Value of the selected answer
- */
selectedAnswer?: AnswerT;
}
-type QuestionWithoutAudio =
- QuestionBase & {
- audioUrl?: never;
- audioAriaLabel?: never;
- transcript?: never;
- audioStartTime?: never;
- audioFinishTime?: never;
- };
+interface MultipleAnswerQuestionBase<
+ AnswerT extends number | string,
+> extends QuestionBase {
+ type: "multiple";
+ correctAnswers: AnswerT[];
+ onChange?: (selectedAnswers: AnswerT[]) => void;
+ selectedAnswers?: AnswerT[];
+}
+
+type AudioProps =
+ | {
+ audioUrl?: never;
+ audioAriaLabel?: never;
+ transcript?: never;
+ audioStartTime?: never;
+ audioFinishTime?: never;
+ }
+ | {
+ audioUrl: string;
+ audioAriaLabel: string;
+ transcript: string;
+ audioStartTime?: number;
+ audioFinishTime?: number;
+ };
+
+export type SingleAnswerQuestion =
+ SingleAnswerQuestionBase & AudioProps;
-type QuestionWithAudio =
- QuestionBase & {
- /**
- * URL to an audio file for the question
- */
- audioUrl: string;
- /**
- * Accessible label for the audio element
- */
- audioAriaLabel: string;
- /**
- * Transcript text for the audio
- */
- transcript: string;
- /**
- * Start time in seconds for the audio segment
- */
- audioStartTime?: number;
- /**
- * Finish time in seconds for the audio segment
- */
- audioFinishTime?: number;
- };
+export type MultipleAnswerQuestion =
+ MultipleAnswerQuestionBase &
+ Omit<
+ MultipleAnswerQuizQuestionProps,
+ | "question"
+ | "answers"
+ | "position"
+ | "disabled"
+ | "required"
+ | "selectedAnswers"
+ | "onChange"
+ > &
+ AudioProps;
export type Question =
- | QuestionWithoutAudio
- | QuestionWithAudio;
+ | SingleAnswerQuestion
+ | MultipleAnswerQuestion;
diff --git a/src/quiz/use-quiz.test.ts b/src/quiz/use-quiz.test.ts
index 9ee248d1..9261f7de 100644
--- a/src/quiz/use-quiz.test.ts
+++ b/src/quiz/use-quiz.test.ts
@@ -1,5 +1,5 @@
-import { renderHook, act } from "@testing-library/react";
-import { vi } from "vitest";
+import { act, renderHook } from "@testing-library/react";
+import { describe, expect, it } from "vitest";
import { useQuiz } from "./use-quiz";
@@ -9,326 +9,83 @@ const validationMessages = {
};
describe("useQuiz", () => {
- it("should return the data correctly", () => {
+ it("awards partial credit for a selected correct multiple answer", () => {
const { result } = renderHook(() =>
useQuiz({
initialQuestions: [
{
- question: "Lorem ipsum dolor sit amet",
+ type: "multiple",
+ question: "Select the correct answers",
answers: [
- { label: "Option 1", value: 1 },
- { label: "Option 2", value: 2 },
- { label: "Option 3", value: 3 },
+ { label: "Option A", value: "A" },
+ { label: "Option B", value: "B" },
+ { label: "Option C", value: "C" },
],
- correctAnswer: 1,
- },
- {
- question: "Consectetur adipiscing elit",
- answers: [
- { label: "Option 1", value: 1 },
- { label: "Option 2", value: 2 },
- { label: "Option 3", value: 3 },
- ],
- correctAnswer: 2,
- },
- ],
- validationMessages,
- passingPercent: 100,
- }),
- );
-
- expect(typeof result.current.validateAnswers).toBe("function");
- expect(result.current.questions).toHaveLength(2);
-
- // Question 1
- expect(result.current.questions[0].question).toBe(
- "Lorem ipsum dolor sit amet",
- );
- expect(result.current.questions[0].answers).toStrictEqual([
- { label: "Option 1", value: 1 },
- { label: "Option 2", value: 2 },
- { label: "Option 3", value: 3 },
- ]);
- expect(result.current.questions[0].selectedAnswer).toBeUndefined();
- expect(typeof result.current.questions[0].onChange).toBe("function");
-
- // Question 2
- expect(result.current.questions[1].question).toBe(
- "Consectetur adipiscing elit",
- );
- expect(result.current.questions[1].answers).toStrictEqual([
- { label: "Option 1", value: 1 },
- { label: "Option 2", value: 2 },
- { label: "Option 3", value: 3 },
- ]);
- expect(result.current.questions[1].selectedAnswer).toBeUndefined();
- expect(typeof result.current.questions[1].onChange).toBe("function");
- });
-
- it("should return the correct initial selected answers if `selectedAnswer` is specified", () => {
- const { result } = renderHook(() =>
- useQuiz({
- initialQuestions: [
- {
- question: "Lorem ipsum dolor sit amet",
- answers: [
- { label: "Option 1", value: 1 },
- { label: "Option 2", value: 2 },
- { label: "Option 3", value: 3 },
- ],
- selectedAnswer: 1,
- correctAnswer: 1,
- },
- {
- question: "Consectetur adipiscing elit",
- answers: [
- { label: "Option 1", value: 1 },
- { label: "Option 2", value: 2 },
- { label: "Option 3", value: 3 },
- ],
- selectedAnswer: 3,
- correctAnswer: 2,
- },
- ],
- validationMessages,
- passingPercent: 100,
- }),
- );
-
- expect(result.current.questions[0].selectedAnswer).toBe(1);
- expect(result.current.questions[1].selectedAnswer).toBe(3);
- });
-
- it("should return the correct selected answers after the selections are updated", () => {
- const { result } = renderHook(() =>
- useQuiz({
- initialQuestions: [
- {
- question: "Lorem ipsum dolor sit amet",
- answers: [
- { label: "Option 1", value: 1 },
- { label: "Option 2", value: 2 },
- { label: "Option 3", value: 3 },
- ],
- selectedAnswer: 1,
- correctAnswer: 1,
- },
- {
- question: "Consectetur adipiscing elit",
- answers: [
- { label: "Option 1", value: 1 },
- { label: "Option 2", value: 2 },
- { label: "Option 3", value: 3 },
- ],
- selectedAnswer: 3,
- correctAnswer: 2,
+ correctAnswers: ["A", "B"],
+ selectedAnswers: ["A"],
},
],
validationMessages,
- passingPercent: 100,
- }),
- );
-
- act(() => result.current.questions[0].onChange(2));
- act(() => result.current.questions[1].onChange(2));
-
- expect(result.current.questions[0].selectedAnswer).toBe(2);
- expect(result.current.questions[1].selectedAnswer).toBe(2);
- });
-
- it("should return the questions array with the correct validation status when `validateAnswers` is called", () => {
- const { result } = renderHook(() =>
- useQuiz({
- initialQuestions: [
- {
- question: "Lorem ipsum dolor sit amet",
- answers: [
- { label: "Option 1", value: 1 },
- { label: "Option 2", value: 2 },
- { label: "Option 3", value: 3 },
- ],
- selectedAnswer: 1,
- correctAnswer: 1,
- },
- {
- question: "Consectetur adipiscing elit",
- answers: [
- { label: "Option 1", value: 1 },
- { label: "Option 2", value: 2 },
- { label: "Option 3", value: 3 },
- ],
- selectedAnswer: 3,
- correctAnswer: 2,
- },
- ],
- validationMessages,
- passingPercent: 100,
+ passingPercent: 50,
}),
);
- expect(result.current.validated).toBe(false);
- expect(result.current.correctAnswerCount).toBeUndefined();
- expect(result.current.grade).toBeUndefined();
-
act(() => {
result.current.validateAnswers();
});
- expect(result.current.questions[0].answers[0].validation?.message).toBe(
- "Correct",
- );
- expect(
- result.current.questions[0].answers[1].validation?.message,
- ).toBeUndefined();
- expect(
- result.current.questions[0].answers[2].validation?.message,
- ).toBeUndefined();
-
- expect(
- result.current.questions[1].answers[0].validation?.message,
- ).toBeUndefined();
- expect(
- result.current.questions[1].answers[1].validation?.message,
- ).toBeUndefined();
- expect(result.current.questions[1].answers[2].validation?.message).toBe(
- "Incorrect",
- );
- expect(result.current.correctAnswerCount).toBe(1);
expect(result.current.grade).toBe(50);
+ expect(result.current.correctAnswerCount).toBe(0);
expect(result.current.validated).toBe(true);
});
- it("should return the questions array with the correct validation status if `showCorrectAnswersOnSuccess` is `true`", () => {
+ it("awards full credit when all correct answers and no incorrect answers are selected", () => {
const { result } = renderHook(() =>
useQuiz({
initialQuestions: [
{
- question: "Lorem ipsum dolor sit amet",
+ type: "multiple",
+ question: "Select the correct answers",
answers: [
- { label: "Option 1", value: 1 },
- { label: "Option 2", value: 2 },
- { label: "Option 3", value: 3 },
+ { label: "Option A", value: "A" },
+ { label: "Option B", value: "B" },
+ { label: "Option C", value: "C" },
],
- selectedAnswer: 1,
- correctAnswer: 1,
- },
- {
- question: "Consectetur adipiscing elit",
- answers: [
- { label: "Option 1", value: 1 },
- { label: "Option 2", value: 2 },
- { label: "Option 3", value: 3 },
- ],
- selectedAnswer: 3,
- correctAnswer: 2,
+ correctAnswers: ["A", "B"],
+ selectedAnswers: ["A", "B"],
},
],
validationMessages,
- passingPercent: 50,
- showCorrectAnswersOnSuccess: true,
+ passingPercent: 100,
}),
);
- expect(result.current.validated).toBe(false);
- expect(result.current.correctAnswerCount).toBeUndefined();
- expect(result.current.grade).toBeUndefined();
-
act(() => {
result.current.validateAnswers();
});
- expect(result.current.questions).toMatchObject([
- {
- question: "Lorem ipsum dolor sit amet",
- answers: [
- {
- label: "Option 1",
- value: 1,
- validation: {
- message: "Correct",
- state: "correct",
- },
- },
- {
- label: "Option 2",
- value: 2,
- },
- {
- label: "Option 3",
- value: 3,
- },
- ],
- onChange: expect.any(Function),
- selectedAnswer: 1,
- correctAnswer: 1,
- },
- {
- question: "Consectetur adipiscing elit",
- answers: [
- {
- label: "Option 1",
- value: 1,
- },
- {
- label: "Option 2",
- value: 2,
- validation: {
- message: "Correct",
- state: "correct",
- },
- },
- {
- label: "Option 3",
- value: 3,
- validation: {
- message: "Incorrect",
- state: "incorrect",
- },
- },
- ],
- onChange: expect.any(Function),
- selectedAnswer: 3,
- correctAnswer: 2,
- },
- ]);
-
+ expect(result.current.grade).toBe(100);
expect(result.current.correctAnswerCount).toBe(1);
- expect(result.current.grade).toBe(50);
- expect(result.current.validated).toBe(true);
});
- it("should call the `onSuccess` function if the quiz results meet the passing grade", () => {
- const onSuccess = vi.fn();
- const onFailure = vi.fn();
-
+ it("awards zero credit when an incorrect answer is selected", () => {
const { result } = renderHook(() =>
useQuiz({
initialQuestions: [
{
- question: "Lorem ipsum dolor sit amet",
- answers: [
- { label: "Option 1", value: 1 },
- { label: "Option 2", value: 2 },
- { label: "Option 3", value: 3 },
- ],
- selectedAnswer: 1,
- correctAnswer: 1,
- },
- {
- question: "Consectetur adipiscing elit",
+ type: "multiple",
+ question: "Select the correct answers",
answers: [
- { label: "Option 1", value: 1 },
- { label: "Option 2", value: 2 },
- { label: "Option 3", value: 3 },
+ { label: "Option A", value: "A" },
+ { label: "Option B", value: "B" },
+ { label: "Option C", value: "C" },
],
- selectedAnswer: 3,
- correctAnswer: 3,
+ correctAnswers: ["A", "B"],
+ selectedAnswers: ["A", "C"],
},
],
validationMessages,
- onSuccess,
- onFailure,
- passingPercent: 100,
+ passingPercent: 1,
}),
);
@@ -336,42 +93,32 @@ describe("useQuiz", () => {
result.current.validateAnswers();
});
- expect(onSuccess).toHaveBeenCalledTimes(1);
- expect(onFailure).toHaveBeenCalledTimes(0);
+ expect(result.current.grade).toBe(0);
+ expect(result.current.correctAnswerCount).toBe(0);
+ expect(result.current.questions[0].answers[0].validation?.state).toBe(
+ "correct",
+ );
+ expect(result.current.questions[0].answers[2].validation?.state).toBe(
+ "incorrect",
+ );
});
- it("should call the `onFailure` function if the quiz results don't meet the passing grade", () => {
- const onSuccess = vi.fn();
- const onFailure = vi.fn();
-
+ it("awards zero credit when no multiple answers are selected", () => {
const { result } = renderHook(() =>
useQuiz({
initialQuestions: [
{
- question: "Lorem ipsum dolor sit amet",
- answers: [
- { label: "Option 1", value: 1 },
- { label: "Option 2", value: 2 },
- { label: "Option 3", value: 3 },
- ],
- selectedAnswer: 1,
- correctAnswer: 2,
- },
- {
- question: "Consectetur adipiscing elit",
+ type: "multiple",
+ question: "Select the correct answers",
answers: [
- { label: "Option 1", value: 1 },
- { label: "Option 2", value: 2 },
- { label: "Option 3", value: 3 },
+ { label: "Option A", value: "A" },
+ { label: "Option B", value: "B" },
],
- selectedAnswer: 3,
- correctAnswer: 3,
+ correctAnswers: ["A", "B"],
},
],
validationMessages,
- onSuccess,
- onFailure,
- passingPercent: 100,
+ passingPercent: 50,
}),
);
@@ -379,42 +126,36 @@ describe("useQuiz", () => {
result.current.validateAnswers();
});
- expect(onSuccess).toHaveBeenCalledTimes(0);
- expect(onFailure).toHaveBeenCalledTimes(1);
+ expect(result.current.grade).toBe(0);
+ expect(result.current.correctAnswerCount).toBe(0);
});
- it("should register the passing grade correctly", () => {
- const onSuccess = vi.fn();
- const onFailure = vi.fn();
-
+ it("calculates the overall grade using partial credit across questions", () => {
const { result } = renderHook(() =>
useQuiz({
initialQuestions: [
{
- question: "Lorem ipsum dolor sit amet",
+ type: "multiple",
+ question: "Question 1",
answers: [
- { label: "Option 1", value: 1 },
- { label: "Option 2", value: 2 },
- { label: "Option 3", value: 3 },
+ { label: "A", value: "A" },
+ { label: "B", value: "B" },
],
- selectedAnswer: 1,
- correctAnswer: 2,
+ correctAnswers: ["A", "B"],
+ selectedAnswers: ["A"],
},
{
- question: "Consectetur adipiscing elit",
+ question: "Question 2",
answers: [
- { label: "Option 1", value: 1 },
- { label: "Option 2", value: 2 },
- { label: "Option 3", value: 3 },
+ { label: "A", value: "A" },
+ { label: "B", value: "B" },
],
- selectedAnswer: 3,
- correctAnswer: 3,
+ correctAnswer: "A",
+ selectedAnswer: "A",
},
],
validationMessages,
- onSuccess,
- onFailure,
- passingPercent: 50,
+ passingPercent: 75,
}),
);
@@ -422,7 +163,7 @@ describe("useQuiz", () => {
result.current.validateAnswers();
});
- expect(onSuccess).toHaveBeenCalledTimes(1);
- expect(onFailure).toHaveBeenCalledTimes(0);
+ expect(result.current.grade).toBe(75);
+ expect(result.current.correctAnswerCount).toBe(1);
});
});
diff --git a/src/quiz/use-quiz.ts b/src/quiz/use-quiz.ts
index 08e4dd64..0d699bca 100644
--- a/src/quiz/use-quiz.ts
+++ b/src/quiz/use-quiz.ts
@@ -1,15 +1,16 @@
import { useState } from "react";
-import { type Question } from "./types";
-import { QuizQuestionAnswer } from "../quiz-question";
+import type { QuizQuestionAnswer } from "../quiz-question";
+import type { Question } from "./types";
-type InitialQuestion = Omit<
- Question,
- "onChange"
+type WithoutOnChange = T extends unknown ? Omit : never;
+
+type InitialQuestion = WithoutOnChange<
+ Question
>;
type ReturnedQuestion = Question & {
- onChange: (selectedAnswer: AnswerT) => void;
+ onChange: (selectedAnswer: AnswerT | AnswerT[]) => void;
};
export interface UseQuizProps {
@@ -33,6 +34,29 @@ export type UseQuizResult = ValidationData & {
validateAnswers: () => void;
};
+const getQuestionScore = (
+ question: Question,
+): number => {
+ if (question.type === "multiple") {
+ const selected = question.selectedAnswers ?? [];
+ const correct = question.correctAnswers;
+
+ if (
+ selected.some((answer) => !correct.includes(answer)) ||
+ correct.length === 0
+ ) {
+ return 0;
+ }
+
+ return (
+ selected.filter((answer) => correct.includes(answer)).length /
+ correct.length
+ );
+ }
+
+ return question.selectedAnswer === question.correctAnswer ? 1 : 0;
+};
+
export const useQuiz = ({
initialQuestions,
validationMessages,
@@ -48,39 +72,68 @@ export const useQuiz = ({
validated: false,
});
- const questionsWithChangeHandling = questions.map((question, index) => ({
- ...question,
- onChange: (selectedAnswer: AnswerT) => {
- // update the selected answer for this question
- setQuestions((prevQuestions) =>
- prevQuestions.map((prevQuestion, prevIndex) =>
- prevIndex === index
- ? { ...prevQuestion, selectedAnswer }
- : prevQuestion,
- ),
- );
- },
- }));
+ const questionsWithChangeHandling = questions.map(
+ (question, index) =>
+ ({
+ ...question,
+ onChange: (selection: AnswerT | AnswerT[]) => {
+ setQuestions((previousQuestions) =>
+ previousQuestions.map((previousQuestion, previousIndex) => {
+ if (previousIndex !== index) {
+ return previousQuestion;
+ }
- const validateAnswers = () => {
- setQuestions((prevQuestion) => {
- const correctCount = prevQuestion.filter(
- ({ selectedAnswer, correctAnswer }) => selectedAnswer === correctAnswer,
- ).length;
+ if (previousQuestion.type === "multiple") {
+ return {
+ ...previousQuestion,
+ selectedAnswers: Array.isArray(selection)
+ ? selection
+ : [selection],
+ };
+ }
- const grade = parseFloat(
- ((correctCount / initialQuestions.length) * 100).toFixed(2),
- );
+ return {
+ ...previousQuestion,
+ selectedAnswer: Array.isArray(selection)
+ ? selection[0]
+ : selection,
+ };
+ }),
+ );
+ },
+ }) as ReturnedQuestion,
+ );
- const updatedQuestions: Question[] = prevQuestion.map(
+ const validateAnswers = () => {
+ setQuestions((previousQuestions) => {
+ const scores = previousQuestions.map(getQuestionScore);
+ const correctCount = scores.filter((score) => score === 1).length;
+ const grade =
+ previousQuestions.length === 0
+ ? 0
+ : parseFloat(
+ (
+ (scores.reduce((total, score) => total + score, 0) /
+ previousQuestions.length) *
+ 100
+ ).toFixed(2),
+ );
+
+ const updatedQuestions: Question[] = previousQuestions.map(
(question) => {
const answersWithValidation = question.answers.map((answer) => {
- let validation: QuizQuestionAnswer["validation"];
-
- // Pass validation to the selected answer
- if (answer.value === question.selectedAnswer) {
- validation =
- answer.value === question.correctAnswer
+ let answerValidation: QuizQuestionAnswer["validation"];
+
+ if (question.type === "multiple") {
+ const selected = question.selectedAnswers ?? [];
+ const correct = question.correctAnswers;
+ const isSelected = selected.includes(answer.value);
+ // const hasIncorrectSelection = selected.some(
+ // (value) => !correct.includes(value),
+ // );
+
+ if (isSelected) {
+ answerValidation = correct.includes(answer.value)
? {
state: "correct",
message: validationMessages.correct,
@@ -89,21 +142,42 @@ export const useQuiz = ({
state: "incorrect",
message: validationMessages.incorrect,
};
- } else {
- // Reveal the correct answer if the results meet the passing grade
- if (
- answer.value === question.correctAnswer &&
+ } else if (
+ correct.includes(answer.value) &&
grade >= passingPercent &&
showCorrectAnswersOnSuccess
) {
- validation = {
+ answerValidation = {
state: "correct",
message: validationMessages.correct,
};
}
+
+ // // Intentionally do not mark unselected answers incorrect.
+ // void hasIncorrectSelection;
+ } else if (answer.value === question.selectedAnswer) {
+ answerValidation =
+ answer.value === question.correctAnswer
+ ? {
+ state: "correct",
+ message: validationMessages.correct,
+ }
+ : {
+ state: "incorrect",
+ message: validationMessages.incorrect,
+ };
+ } else if (
+ answer.value === question.correctAnswer &&
+ grade >= passingPercent &&
+ showCorrectAnswersOnSuccess
+ ) {
+ answerValidation = {
+ state: "correct",
+ message: validationMessages.correct,
+ };
}
- return { ...answer, validation };
+ return { ...answer, validation: answerValidation };
});
return { ...question, answers: answersWithValidation };
@@ -117,13 +191,9 @@ export const useQuiz = ({
});
if (grade >= passingPercent) {
- if (onSuccess) {
- onSuccess();
- }
+ onSuccess?.();
} else {
- if (onFailure) {
- onFailure();
- }
+ onFailure?.();
}
return updatedQuestions;