Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions src/colors.css
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
--yellow45: #ffbf00;
--yellow50: #f1be32;
--yellow70: #8a6d3b;
--yellow75: #7b5e2c;
--yellow80: #66512c;
--yellow90: #4d3800;

Expand Down
74 changes: 46 additions & 28 deletions src/index.ts
Original file line number Diff line number Diff line change
@@ -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";
109 changes: 109 additions & 0 deletions src/multiple-answer-quiz-question/answer.tsx
Original file line number Diff line number Diff line change
@@ -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<AnswerT> {
checked: boolean;
disabled?: boolean;
required?: boolean;
onChange: (checked: boolean) => void;
}

const ValidationMessage = ({ state, message }: QuizQuestionValidation) => (
<p
className={
state === "correct" ? "text-background-success" : "text-background-danger"
}
>
<FontAwesomeIcon
icon={state === "correct" ? faCheck : faXmark}
className={
state === "correct"
? "text-background-success me-[8px] fa-width-auto"
: "text-background-danger me-[8px] fa-width-auto"
}
/>
{message}
</p>
);

export const MultipleAnswer = <AnswerT extends number | string>({
value,
label,
checked,
disabled,
required,
onChange,
validation,
feedback,
action,
}: MultipleAnswerProps<AnswerT>) => {
const id = useId();
const labelId = `${id}-label`;

return (
<div
className={[
"grid grid-cols-1 grid-rows-[auto_auto]",
"border-x-4 border-t-4 last:border-b-4",
"border-background-tertiary bg-background-primary",
validation?.state === "correct" ? "border-l-background-success" : "",
validation?.state === "incorrect" ? "border-l-background-danger" : "",
].join(" ")}
>
<div className="flex items-center p-[20px]">
<input
id={id}
type="checkbox"
value={String(value)}
checked={checked}
disabled={disabled}
required={required}
aria-describedby={labelId}
onChange={(event) => onChange(event.currentTarget.checked)}
className="me-[15px] h-5 w-5 shrink-0 accent-current"
/>

<label
id={labelId}
htmlFor={id}
className="m-0 min-w-0 break-words text-foreground-primary"
>
{label}
</label>

{action && (
<button
type="button"
className="ms-auto"
aria-label={action.ariaLabel}
aria-describedby={labelId}
onClick={action.onClick}
>
Listen
</button>
)}
</div>

{(validation || (checked && feedback)) && (
<div className="col-span-2 ps-[20px] pb-[20px] text-foreground-primary [&>p:last-child]:m-0">
{validation && (
<ValidationMessage
state={validation.state}
message={validation.message}
/>
)}

{checked && feedback && <div>{feedback}</div>}
</div>
)}
</div>
);
};
2 changes: 2 additions & 0 deletions src/multiple-answer-quiz-question/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export { MultipleAnswerQuizQuestion } from "./multiple-answer-quiz-question";
export type { MultipleAnswerQuizQuestionProps } from "./types";
Original file line number Diff line number Diff line change
@@ -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<typeof MultipleAnswerQuizQuestion> = {
title: "Components/MultipleAnswerQuizQuestion",
component: MultipleAnswerQuizQuestion,
};

export default meta;

type Story = StoryObj<typeof MultipleAnswerQuizQuestion>;

const InteractiveExample = () => {
const [selectedAnswers, setSelectedAnswers] = useState<string[]>([]);

return (
<div className="max-w-2xl">
<MultipleAnswerQuizQuestion
question="Which of the following are JavaScript data types?"
answers={[
{ label: "String", value: "string" },
{ label: "Boolean", value: "boolean" },
{ label: "HTML", value: "html" },
{ label: "Number", value: "number" },
]}
selectedAnswers={selectedAnswers}
onChange={setSelectedAnswers}
/>

<p className="mt-4">
Selected answers: {selectedAnswers.join(", ") || "None"}
</p>
</div>
);
};

export const Default: Story = {
render: () => <InteractiveExample />,
};

export const Disabled: Story = {
render: () => (
<div className="max-w-2xl">
<MultipleAnswerQuizQuestion
question="Select all correct answers"
answers={[
{ label: "Option A", value: "A" },
{ label: "Option B", value: "B" },
{ label: "Option C", value: "C" },
]}
selectedAnswers={["A"]}
disabled
/>
</div>
),
};
Original file line number Diff line number Diff line change
@@ -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("<MultipleAnswerQuizQuestion />", () => {
it("renders the question and checkbox answers", () => {
render(
<MultipleAnswerQuizQuestion
question="Select all correct answers"
answers={answers}
/>,
);

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(
<MultipleAnswerQuizQuestion
question="Select all correct answers"
answers={answers}
selectedAnswers={["A", "C"]}
/>,
);

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(
<MultipleAnswerQuizQuestion
question="Select all correct answers"
answers={answers}
selectedAnswers={["A"]}
onChange={onChange}
/>,
);

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(
<MultipleAnswerQuizQuestion
question="Select all correct answers"
answers={answers}
selectedAnswers={["A", "B"]}
onChange={onChange}
/>,
);

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(
<MultipleAnswerQuizQuestion
question="Select all correct answers"
answers={answers}
disabled
onChange={onChange}
/>,
);

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(
<MultipleAnswerQuizQuestion
question="Select all correct answers"
answers={answers}
position={2}
/>,
);

expect(
screen.getByRole("group", {
name: "2. Select all correct answers",
}),
).toBeInTheDocument();
});
});
Loading