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/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" + /> + + + + {action && ( + + )} +
+ + {(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; 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)", },