From 17fd382a209e8eaad65aa3784016e0c38b4a9d0e Mon Sep 17 00:00:00 2001
From: Evan Yamanishi
Date: Fri, 13 Dec 2024 09:12:33 -0500
Subject: [PATCH] refactor: update multiple choice to reflect anatomy (#366)
* refactor: reflect multiple choice's updated anatomy
The "Multiple Choice" pattern contains two views: "Question" and "Feedback"
so now the implementation follows that same structure, using "Multiple Choice" as the namespace.
* docs(storybook): simplify multiple choice stories
* docs: update API docs for the main multiple choice page
* chore: rename stories
---
.storybook/main.ts | 32 ++---
packages/core/src/_system.scss | 1 -
.../src/components/feedback-modal/index.scss | 34 ------
.../src/components/multiple-choice/index.scss | 35 +++++-
.../tokens.scss | 1 -
packages/core/src/index.scss | 1 -
.../src/components/FeedbackModal/index.ts | 2 -
packages/react/src/components/index.ts | 1 -
packages/react/src/index.ts | 5 +-
.../patterns/MultipleChoice/AnswerChoice.tsx | 28 -----
.../MultipleChoice/MultipleChoice.mdx | 21 ++++
.../MultipleChoice/MultipleChoice.stories.tsx | 114 ++++++++++++++++++
.../MultipleChoice/PatternExample.tsx | 43 -------
.../patterns/MultipleChoice/hooks/index.ts | 2 +
.../patterns/MultipleChoice/hooks/types.ts | 20 +++
.../{ => hooks}/useMultipleChoice.tsx | 25 +---
.../patterns/MultipleChoice/index.stories.tsx | 58 ---------
.../src/patterns/MultipleChoice/index.ts | 15 ++-
.../src/patterns/MultipleChoice/types.ts | 8 --
.../views/Feedback/Feedback.tsx} | 15 +--
.../views/Feedback}/index.stories.tsx | 14 +--
.../views/Feedback}/index.test.tsx | 14 +--
.../MultipleChoice/views/Feedback/index.ts | 2 +
.../MultipleChoice/views/Feedback}/tokens.ts | 2 +-
.../MultipleChoice/views/Feedback}/types.ts | 4 +-
.../views/Question/AnswerChoice.tsx | 28 +++++
.../Question/Question.tsx} | 56 ++++-----
.../Question/QuestionFraming.tsx} | 2 +-
.../Question/QuestionInstructions.tsx} | 0
.../Question/QuestionStem.tsx} | 2 +-
.../MultipleChoice/views/Question/index.ts | 2 +
.../{ => views/Question}/styles.ts | 2 +-
.../MultipleChoice/views/Question/types.ts | 66 ++++++++++
.../{ => views/Question}/utils.ts | 8 +-
website/docs/patterns/multiple-choice.mdx | 67 +++++-----
35 files changed, 411 insertions(+), 319 deletions(-)
delete mode 100644 packages/core/src/components/feedback-modal/index.scss
rename packages/core/src/components/{feedback-modal => multiple-choice}/tokens.scss (76%)
delete mode 100644 packages/react/src/components/FeedbackModal/index.ts
delete mode 100644 packages/react/src/patterns/MultipleChoice/AnswerChoice.tsx
create mode 100644 packages/react/src/patterns/MultipleChoice/MultipleChoice.mdx
create mode 100644 packages/react/src/patterns/MultipleChoice/MultipleChoice.stories.tsx
delete mode 100644 packages/react/src/patterns/MultipleChoice/PatternExample.tsx
create mode 100644 packages/react/src/patterns/MultipleChoice/hooks/index.ts
create mode 100644 packages/react/src/patterns/MultipleChoice/hooks/types.ts
rename packages/react/src/patterns/MultipleChoice/{ => hooks}/useMultipleChoice.tsx (60%)
delete mode 100644 packages/react/src/patterns/MultipleChoice/index.stories.tsx
delete mode 100644 packages/react/src/patterns/MultipleChoice/types.ts
rename packages/react/src/{components/FeedbackModal/FeedbackModal.tsx => patterns/MultipleChoice/views/Feedback/Feedback.tsx} (77%)
rename packages/react/src/{components/FeedbackModal => patterns/MultipleChoice/views/Feedback}/index.stories.tsx (73%)
rename packages/react/src/{components/FeedbackModal => patterns/MultipleChoice/views/Feedback}/index.test.tsx (83%)
create mode 100644 packages/react/src/patterns/MultipleChoice/views/Feedback/index.ts
rename packages/react/src/{components/FeedbackModal => patterns/MultipleChoice/views/Feedback}/tokens.ts (85%)
rename packages/react/src/{components/FeedbackModal => patterns/MultipleChoice/views/Feedback}/types.ts (71%)
create mode 100644 packages/react/src/patterns/MultipleChoice/views/Question/AnswerChoice.tsx
rename packages/react/src/patterns/MultipleChoice/{MultipleChoice.tsx => views/Question/Question.tsx} (56%)
rename packages/react/src/patterns/MultipleChoice/{Intro.tsx => views/Question/QuestionFraming.tsx} (57%)
rename packages/react/src/patterns/MultipleChoice/{Instructions.tsx => views/Question/QuestionInstructions.tsx} (100%)
rename packages/react/src/patterns/MultipleChoice/{Stem.tsx => views/Question/QuestionStem.tsx} (57%)
create mode 100644 packages/react/src/patterns/MultipleChoice/views/Question/index.ts
rename packages/react/src/patterns/MultipleChoice/{ => views/Question}/styles.ts (85%)
create mode 100644 packages/react/src/patterns/MultipleChoice/views/Question/types.ts
rename packages/react/src/patterns/MultipleChoice/{ => views/Question}/utils.ts (63%)
diff --git a/.storybook/main.ts b/.storybook/main.ts
index 9675a35aa..dcba06e49 100644
--- a/.storybook/main.ts
+++ b/.storybook/main.ts
@@ -1,28 +1,28 @@
// cspell:ignore autodocs
-import { StorybookConfig } from '@storybook/react-vite';
+import { StorybookConfig } from "@storybook/react-vite";
const config: StorybookConfig = {
- core: {
- disableTelemetry: true
- },
+ core: {
+ disableTelemetry: true,
+ },
- docs: {
- autodocs: true
- },
+ docs: {
+ autodocs: true,
+ },
- framework: {
- name: "@storybook/react-vite",
- options: {
+ framework: {
+ name: "@storybook/react-vite",
+ options: {
strictMode: true,
},
- },
+ },
- stories: ['../packages/react/src/**/*.stories.{ts,tsx,mdx}'],
-
- addons: [
- '@storybook/addon-essentials',
- '@storybook/addon-a11y',
+ stories: [
+ "../packages/react/src/**/*.mdx",
+ "../packages/react/src/**/*.stories.@(js|jsx|mjs|ts|tsx)",
],
+
+ addons: ["@storybook/addon-essentials", "@storybook/addon-a11y"],
};
export default config;
diff --git a/packages/core/src/_system.scss b/packages/core/src/_system.scss
index 87a791dfd..089377242 100644
--- a/packages/core/src/_system.scss
+++ b/packages/core/src/_system.scss
@@ -29,7 +29,6 @@
@forward 'components/checkbox' as checkbox-*;
@forward 'components/disclosure' as disclosure-*;
@forward 'components/dropdown' as dropdown-*;
-@forward 'components/feedback-modal' as feedback-modal-*;
@forward 'components/field' as field-*;
@forward 'components/icon' as icon-*;
@forward 'components/link' as link-*;
diff --git a/packages/core/src/components/feedback-modal/index.scss b/packages/core/src/components/feedback-modal/index.scss
deleted file mode 100644
index 849211dc1..000000000
--- a/packages/core/src/components/feedback-modal/index.scss
+++ /dev/null
@@ -1,34 +0,0 @@
-@use 'tokens';
-@use '../../util';
-
-@mixin style {
- @include util.declare(tokens.$name) {
- .nds-#{tokens.$name} {
- &__container {
- display: flex;
- gap: 1rem;
- }
-
- &__icon-container {
- min-width: tokens.$icon-width;
- }
-
- &__icon {
- width: tokens.$icon-width;
- height: tokens.$icon-width;
-
- &--correct {
- color: tokens.$correct;
- }
-
- &--incorrect {
- color: tokens.$incorrect;
- }
- }
-
- &__heading {
- margin-bottom: 0;
- }
- }
- }
-}
diff --git a/packages/core/src/components/multiple-choice/index.scss b/packages/core/src/components/multiple-choice/index.scss
index 97626d110..7ab87dfaa 100644
--- a/packages/core/src/components/multiple-choice/index.scss
+++ b/packages/core/src/components/multiple-choice/index.scss
@@ -1,9 +1,11 @@
+@use 'tokens';
@use '../../util';
@mixin style {
@include util.declare('multiple-choice') {
- .nds-multiple-choice {
- &__intro {
+ // Styles for the question view
+ .nds-mc-question {
+ &__framing {
margin-bottom: 0.25rem;
}
@@ -44,5 +46,34 @@
font-weight: bold;
}
}
+
+ // Styles for the feedback view, which extends the modal dialog
+ .nds-mc-feedback {
+ &__container {
+ display: flex;
+ gap: 1rem;
+ }
+
+ &__icon-container {
+ min-width: tokens.$icon-width;
+ }
+
+ &__icon {
+ width: tokens.$icon-width;
+ height: tokens.$icon-width;
+
+ &--correct {
+ color: tokens.$correct;
+ }
+
+ &--incorrect {
+ color: tokens.$incorrect;
+ }
+ }
+
+ &__heading {
+ margin-bottom: 0;
+ }
+ }
}
}
diff --git a/packages/core/src/components/feedback-modal/tokens.scss b/packages/core/src/components/multiple-choice/tokens.scss
similarity index 76%
rename from packages/core/src/components/feedback-modal/tokens.scss
rename to packages/core/src/components/multiple-choice/tokens.scss
index f6e899e36..e08a006c2 100644
--- a/packages/core/src/components/feedback-modal/tokens.scss
+++ b/packages/core/src/components/multiple-choice/tokens.scss
@@ -1,4 +1,3 @@
-$name: 'feedback-modal';
$icon-width: 2.5rem;
$correct: var(--nds-green-60);
$incorrect: var(--nds-red-60);
diff --git a/packages/core/src/index.scss b/packages/core/src/index.scss
index b963541e0..b16f0afd7 100644
--- a/packages/core/src/index.scss
+++ b/packages/core/src/index.scss
@@ -90,7 +90,6 @@
@include nds.checkbox-style;
@include nds.disclosure-style;
@include nds.dropdown-style;
- @include nds.feedback-modal-style;
@include nds.field-style;
@include nds.icon-style;
@include nds.link-style;
diff --git a/packages/react/src/components/FeedbackModal/index.ts b/packages/react/src/components/FeedbackModal/index.ts
deleted file mode 100644
index 2bd7a2ac5..000000000
--- a/packages/react/src/components/FeedbackModal/index.ts
+++ /dev/null
@@ -1,2 +0,0 @@
-export { FeedbackModal } from './FeedbackModal';
-export type { FeedbackModalProps } from './types';
diff --git a/packages/react/src/components/index.ts b/packages/react/src/components/index.ts
index ca8fb2e6f..5276f02e6 100644
--- a/packages/react/src/components/index.ts
+++ b/packages/react/src/components/index.ts
@@ -6,7 +6,6 @@ export * from './Checkbox';
export * from './ChoiceField';
export * from './Disclosure';
export * from './Dropdown';
-export * from './FeedbackModal';
export * from './Field';
export * from './Icon';
export * from './Link';
diff --git a/packages/react/src/index.ts b/packages/react/src/index.ts
index 58c00f259..7bf030479 100644
--- a/packages/react/src/index.ts
+++ b/packages/react/src/index.ts
@@ -1,10 +1,11 @@
// Public components
export * from './components';
+// Public patterns
+export * from './patterns';
+
// Public providers
export * from './providers';
// Public utilities
export * from './utilities';
-
-export * from './patterns';
diff --git a/packages/react/src/patterns/MultipleChoice/AnswerChoice.tsx b/packages/react/src/patterns/MultipleChoice/AnswerChoice.tsx
deleted file mode 100644
index 55b07768c..000000000
--- a/packages/react/src/patterns/MultipleChoice/AnswerChoice.tsx
+++ /dev/null
@@ -1,28 +0,0 @@
-import React, { useCallback } from 'react';
-import { Radio } from '../../components/Radio';
-import { styles } from './styles';
-import { OnSelectInput } from './types';
-
-interface AnswerChoiceProps {
- label?: string;
- checked?: boolean;
- value?: number;
- onSelect?: (input: OnSelectInput) => void;
- children: React.ReactNode;
-}
-
-export const AnswerChoice = ({ label, children, onSelect, value, checked }: AnswerChoiceProps) => {
- const onChange = useCallback(() => {
- if (value === undefined || !label || !onSelect) {
- return;
- }
- onSelect({ index: value, label });
- }, [onSelect, value, label]);
-
- return (
-
- {label}.
- {children}
-
- );
-};
diff --git a/packages/react/src/patterns/MultipleChoice/MultipleChoice.mdx b/packages/react/src/patterns/MultipleChoice/MultipleChoice.mdx
new file mode 100644
index 000000000..b5831b32e
--- /dev/null
+++ b/packages/react/src/patterns/MultipleChoice/MultipleChoice.mdx
@@ -0,0 +1,21 @@
+import { ArgsTable, Canvas, Meta } from '@storybook/blocks';
+import { MultipleChoiceQuestion, MultipleChoiceFeedback } from '.';
+import * as MultipleChoiceStories from './MultipleChoice.stories';
+
+
+
+# Multiple Choice
+
+The multiple choice pattern exposes a component for the question view and a component for the feedback view.
+
+## Question view props
+
+The `` component is the initial view for the pattern.
+
+
+
+## Feedback view props
+
+The `` view extends our [Modal dialog](?path=/docs/modal--docs) and should be shown after the user answers the question.
+
+
diff --git a/packages/react/src/patterns/MultipleChoice/MultipleChoice.stories.tsx b/packages/react/src/patterns/MultipleChoice/MultipleChoice.stories.tsx
new file mode 100644
index 000000000..8132c21a4
--- /dev/null
+++ b/packages/react/src/patterns/MultipleChoice/MultipleChoice.stories.tsx
@@ -0,0 +1,114 @@
+import React, { useCallback, useEffect, useMemo, useState } from 'react';
+import type { Meta, StoryObj } from '@storybook/react';
+import { MultipleChoiceQuestion, MultipleChoiceFeedback, useMultipleChoice } from '.';
+import { Button } from '../../components/Button';
+import { Modal } from '../../components/Modal';
+
+/**
+ * These props are not part of the Multiple Choice pattern, but provide an example
+ * of how users might implement functionality on top of the pattern.
+ */
+interface CustomStoryProps {
+ attemptCount?: number;
+}
+
+type StoryProps = Omit, 'instructions'> &
+ CustomStoryProps;
+
+const MultipleChoice = ({ attemptCount, ...args }: StoryProps) => {
+ const [readOnly, setReadOnly] = useState(false);
+ const [modalOpen, setModalOpen] = useState(false);
+ const [remainingAttempts, setRemainingAttempts] = useState(attemptCount || 1);
+ const { questionState, setStatus, modalState } = useMultipleChoice(args.choices);
+
+ const handleSubmit = useCallback(
+ (event: React.FormEvent) => {
+ event.preventDefault();
+
+ switch (questionState.selected) {
+ case undefined: {
+ setModalOpen(true);
+ setStatus('unanswered');
+ break;
+ }
+ case 1: {
+ setStatus('correct');
+ setRemainingAttempts(0);
+ break;
+ }
+ default: {
+ setStatus('incorrect');
+ if (attemptCount !== undefined) {
+ setRemainingAttempts(remainingAttempts - 1);
+ }
+ }
+ }
+ },
+ [setStatus, questionState.selected, attemptCount, remainingAttempts],
+ );
+
+ useEffect(() => {
+ if (remainingAttempts === 0) setReadOnly(true);
+ }, [remainingAttempts]);
+
+ const instructions = useMemo(() => {
+ if (attemptCount === undefined) {
+ return undefined;
+ }
+ if (questionState.status === 'correct') {
+ return 'Correct answer selected!';
+ }
+ return `Select an answer. You have ${remainingAttempts} attempts remaining.`;
+ }, [questionState.status, attemptCount, remainingAttempts]);
+
+ return (
+
+ );
+};
+
+const meta: Meta = {
+ title: 'Patterns/Multiple Choice',
+ component: MultipleChoiceQuestion,
+ render: MultipleChoice,
+ args: {
+ framing:
+ 'Three people are present when a pregnant person suddenly goes into labor and gives birth in a bank lobby.',
+ stem: 'Which of the people is likely to best remember the event afterward?',
+ choices: [
+ 'Jayvon, who had opened a checking account at the branch that same day',
+ 'Ibrahim, who is taking propranolol to control his blood pressure',
+ 'Huong, who was born with Urbach-Wiethe syndrome and lacks an amygdala',
+ ],
+ },
+};
+
+export default meta;
+type Story = StoryObj;
+
+export const Example: Story = {};
+
+export const ExampleWithInstructions: Story = {
+ args: {
+ attemptCount: 2,
+ },
+};
diff --git a/packages/react/src/patterns/MultipleChoice/PatternExample.tsx b/packages/react/src/patterns/MultipleChoice/PatternExample.tsx
deleted file mode 100644
index 95a494cb9..000000000
--- a/packages/react/src/patterns/MultipleChoice/PatternExample.tsx
+++ /dev/null
@@ -1,43 +0,0 @@
-import React, { useCallback } from 'react';
-import { Button } from '../../components/Button';
-import { FeedbackModal } from '../../components/FeedbackModal';
-import { MultipleChoice } from './MultipleChoice';
-import { useMultipleChoice } from './useMultipleChoice';
-
-interface PatternExampleProps {
- choices: string[];
-}
-
-export const PatternExample = ({ choices }: PatternExampleProps) => {
- const { questionState, setStatus, modalState } = useMultipleChoice(choices);
-
- const handleSubmit = useCallback(
- (event: React.FormEvent) => {
- event.preventDefault();
-
- if (questionState.selected === 1) {
- setStatus('correct');
- } else {
- setStatus('incorrect');
- }
- },
- [setStatus, questionState.selected],
- );
-
- return (
-
- );
-};
diff --git a/packages/react/src/patterns/MultipleChoice/hooks/index.ts b/packages/react/src/patterns/MultipleChoice/hooks/index.ts
new file mode 100644
index 000000000..5446aefad
--- /dev/null
+++ b/packages/react/src/patterns/MultipleChoice/hooks/index.ts
@@ -0,0 +1,2 @@
+export * from './useMultipleChoice';
+export type * from './types';
diff --git a/packages/react/src/patterns/MultipleChoice/hooks/types.ts b/packages/react/src/patterns/MultipleChoice/hooks/types.ts
new file mode 100644
index 000000000..25fcc2445
--- /dev/null
+++ b/packages/react/src/patterns/MultipleChoice/hooks/types.ts
@@ -0,0 +1,20 @@
+import { QuestionProps } from '../views/Question/types';
+import { FeedbackProps } from '../views/Feedback/types';
+
+export type MultipleChoiceStatus = 'unanswered' | 'correct' | 'incorrect';
+
+type QuestionState = Pick;
+
+type FeedbackState = Pick<
+ FeedbackProps,
+ 'isOpen' | 'isCorrect' | 'choiceLabel' | 'choiceText' | 'onRequestClose'
+>;
+
+export interface MultipleChoiceState {
+ /** Props for the question component. */
+ questionState: QuestionState;
+ /** Props for the feedback component. */
+ modalState: FeedbackState;
+ /** A setter function that updates both the question and feedback state. */
+ setStatus: (status: MultipleChoiceStatus) => void;
+}
diff --git a/packages/react/src/patterns/MultipleChoice/useMultipleChoice.tsx b/packages/react/src/patterns/MultipleChoice/hooks/useMultipleChoice.tsx
similarity index 60%
rename from packages/react/src/patterns/MultipleChoice/useMultipleChoice.tsx
rename to packages/react/src/patterns/MultipleChoice/hooks/useMultipleChoice.tsx
index 372464c7e..5fb5699d9 100644
--- a/packages/react/src/patterns/MultipleChoice/useMultipleChoice.tsx
+++ b/packages/react/src/patterns/MultipleChoice/hooks/useMultipleChoice.tsx
@@ -1,29 +1,16 @@
import { useState, useEffect, useMemo } from 'react';
-import { FeedbackModalProps } from '../../components/FeedbackModal/types';
-import { MultipleChoiceStatus, OnSelectInput } from './types';
-
-interface MultipleChoiceState {
- questionState: {
- status: 'unanswered' | 'correct' | 'incorrect';
- onSelect?: (input: OnSelectInput) => void;
- selected?: number;
- choices: string[];
- };
- modalState: Pick<
- FeedbackModalProps,
- 'isOpen' | 'isCorrect' | 'choiceLabel' | 'choiceText' | 'onRequestClose'
- >;
- setStatus: (status: MultipleChoiceStatus) => void;
-}
+import { AnswerChoiceProps } from '../views/Question/types';
+import { MultipleChoiceState } from './types';
export function useMultipleChoice(choices: string[]): MultipleChoiceState {
- const [status, setStatus] = useState('unanswered');
+ const [status, setStatus] =
+ useState('unanswered');
const [selected, setSelected] = useState(undefined);
const [modalOpen, setModalOpen] = useState(false);
const questionState = useMemo(() => {
- const onSelect = (input: OnSelectInput) => {
- setSelected(input.index);
+ const onSelect: NonNullable = (choice) => {
+ setSelected(choice.index);
setStatus('unanswered');
};
diff --git a/packages/react/src/patterns/MultipleChoice/index.stories.tsx b/packages/react/src/patterns/MultipleChoice/index.stories.tsx
deleted file mode 100644
index 337ddff1c..000000000
--- a/packages/react/src/patterns/MultipleChoice/index.stories.tsx
+++ /dev/null
@@ -1,58 +0,0 @@
-import React from 'react';
-import type { Meta, StoryObj } from '@storybook/react';
-import { MultipleChoice } from './MultipleChoice';
-import { PatternExample as PatternExampleComponent } from './PatternExample';
-
-const meta: Meta = {
- component: MultipleChoice,
-};
-
-export default meta;
-type Story = StoryObj;
-
-const choices = [
- 'Jayvon, who had opened a checking account at the branch that same day',
- 'Ibrahim, who is taking propranolol to control his blood pressure',
- 'Huong, who was born with Urbach-Wiethe syndrome and lacks an amygdala',
-];
-
-export const Unanswered: Story = {
- args: {
- intro:
- 'Three people are present when a pregnant person suddenly goes into labor and gives birth in a bank lobby.',
- stem: 'Which of the people is likely to best remember the event afterward?',
- instructions: 'Select one that applies. You have 2 attempts remaining.',
- status: 'unanswered',
- choices,
- },
-};
-
-export const Correct: Story = {
- args: {
- intro:
- 'Three people are present when a pregnant person suddenly goes into labor and gives birth in a bank lobby.',
- stem: 'Which of the people is likely to best remember the event afterward?',
- instructions: 'Select one that applies. You have 2 attempts remaining.',
- status: 'correct',
- labelType: 'upper-roman',
- selected: 2,
- choices,
- },
-};
-
-export const Incorrect: Story = {
- args: {
- intro:
- 'Three people are present when a pregnant person suddenly goes into labor and gives birth in a bank lobby.',
- stem: 'Which of the people is likely to best remember the event afterward?',
- instructions: 'Select one that applies. You have 2 attempts remaining.',
- status: 'incorrect',
- labelType: 'upper-alpha',
- selected: 1,
- choices,
- },
-};
-
-export const PatternExample: Story = {
- render: () => ,
-};
diff --git a/packages/react/src/patterns/MultipleChoice/index.ts b/packages/react/src/patterns/MultipleChoice/index.ts
index b0d498d47..5ce1bd6c1 100644
--- a/packages/react/src/patterns/MultipleChoice/index.ts
+++ b/packages/react/src/patterns/MultipleChoice/index.ts
@@ -1,2 +1,13 @@
-export * from './MultipleChoice';
-export * from './useMultipleChoice';
+/**
+ * The Question and Feedback views are named generically and then exported with
+ * the MultipleChoice prefix so that we can reuse the views for patterns that
+ * may not be "multiple choice" question types.
+ */
+
+export { Question as MultipleChoiceQuestion } from './views/Question';
+export type { QuestionProps as MultipleChoiceQuestionProps } from './views/Question';
+
+export { Feedback as MultipleChoiceFeedback } from './views/Feedback';
+export type { FeedbackProps as MultipleChoiceFeedbackProps } from './views/Feedback';
+
+export * from './hooks';
diff --git a/packages/react/src/patterns/MultipleChoice/types.ts b/packages/react/src/patterns/MultipleChoice/types.ts
deleted file mode 100644
index 7ef8254d0..000000000
--- a/packages/react/src/patterns/MultipleChoice/types.ts
+++ /dev/null
@@ -1,8 +0,0 @@
-export type LabelType = 'lower-alpha' | 'upper-alpha' | 'lower-roman' | 'upper-roman' | 'decimal';
-
-export type OnSelectInput = {
- label: string;
- index: number;
-};
-
-export type MultipleChoiceStatus = 'unanswered' | 'correct' | 'incorrect';
diff --git a/packages/react/src/components/FeedbackModal/FeedbackModal.tsx b/packages/react/src/patterns/MultipleChoice/views/Feedback/Feedback.tsx
similarity index 77%
rename from packages/react/src/components/FeedbackModal/FeedbackModal.tsx
rename to packages/react/src/patterns/MultipleChoice/views/Feedback/Feedback.tsx
index d34c18e19..156d1393c 100644
--- a/packages/react/src/components/FeedbackModal/FeedbackModal.tsx
+++ b/packages/react/src/patterns/MultipleChoice/views/Feedback/Feedback.tsx
@@ -1,9 +1,9 @@
import React from 'react';
import classNames from 'classnames';
-import { Modal } from '../Modal';
-import { Button } from '../Button';
-import { Icon } from '../Icon';
-import { FeedbackModalProps } from './types';
+import { Button } from '../../../../components/Button';
+import { Icon } from '../../../../components/Icon';
+import { Modal } from '../../../../components/Modal';
+import { FeedbackProps } from './types';
import { css } from './tokens';
/**
@@ -11,12 +11,7 @@ import { css } from './tokens';
*
* Supplementary Feedback can be passed in `children`.
*/
-export const FeedbackModal = ({
- isCorrect,
- choiceLabel,
- choiceText,
- ...modalProps
-}: FeedbackModalProps) => {
+export const Feedback = ({ isCorrect, choiceLabel, choiceText, ...modalProps }: FeedbackProps) => {
const title = isCorrect ? 'Correct' : 'Incorrect';
const icon = isCorrect ? 'check-circle' : 'cancel';
diff --git a/packages/react/src/components/FeedbackModal/index.stories.tsx b/packages/react/src/patterns/MultipleChoice/views/Feedback/index.stories.tsx
similarity index 73%
rename from packages/react/src/components/FeedbackModal/index.stories.tsx
rename to packages/react/src/patterns/MultipleChoice/views/Feedback/index.stories.tsx
index ab80a1695..a95fac6fc 100644
--- a/packages/react/src/components/FeedbackModal/index.stories.tsx
+++ b/packages/react/src/patterns/MultipleChoice/views/Feedback/index.stories.tsx
@@ -1,23 +1,23 @@
import React from 'react';
import type { Meta, StoryObj } from '@storybook/react';
-import { FeedbackModal } from '.';
+import { Feedback } from '.';
-const meta: Meta = {
- title: 'FeedbackModal',
- component: FeedbackModal,
+const meta: Meta = {
+ title: 'Feedback',
+ component: Feedback,
};
export default meta;
-type Story = StoryObj;
+type Story = StoryObj;
const template: Story = {
render: ({ ...args }) => (
-
+
Answer feedback lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod
tempor incididunt ut labore et dolore magna aliqua.
-
+
),
};
diff --git a/packages/react/src/components/FeedbackModal/index.test.tsx b/packages/react/src/patterns/MultipleChoice/views/Feedback/index.test.tsx
similarity index 83%
rename from packages/react/src/components/FeedbackModal/index.test.tsx
rename to packages/react/src/patterns/MultipleChoice/views/Feedback/index.test.tsx
index b49efb985..17256c719 100644
--- a/packages/react/src/components/FeedbackModal/index.test.tsx
+++ b/packages/react/src/patterns/MultipleChoice/views/Feedback/index.test.tsx
@@ -2,18 +2,18 @@ import test from 'ava';
import sinon from 'sinon';
import React from 'react';
import { cleanup, fireEvent, render, screen } from '@testing-library/react';
-import { FeedbackModal } from '.';
+import { Feedback } from '.';
test.afterEach.always(cleanup);
test('correct', (t) => {
render(
-
+
Answer feedback lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod
tempor incididunt ut labore et dolore magna aliqua.
- ,
+ ,
);
const dialog = screen.queryByRole('dialog', { name: /correct/i });
@@ -22,12 +22,12 @@ test('correct', (t) => {
test('incorrect', (t) => {
render(
-
+
Answer feedback lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod
tempor incididunt ut labore et dolore magna aliqua.
- ,
+ ,
);
const dialog = screen.queryByRole('dialog', { name: /incorrect/i });
@@ -38,7 +38,7 @@ test('on close button', (t) => {
const closeMock = sinon.mock();
render(
- {
Answer feedback lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod
tempor incididunt ut labore et dolore magna aliqua.
- ,
+ ,
);
const closeButtons = screen.queryAllByRole('button', { name: /close/i });
diff --git a/packages/react/src/patterns/MultipleChoice/views/Feedback/index.ts b/packages/react/src/patterns/MultipleChoice/views/Feedback/index.ts
new file mode 100644
index 000000000..1e1842f4b
--- /dev/null
+++ b/packages/react/src/patterns/MultipleChoice/views/Feedback/index.ts
@@ -0,0 +1,2 @@
+export { Feedback } from './Feedback';
+export type { FeedbackProps } from './types';
diff --git a/packages/react/src/components/FeedbackModal/tokens.ts b/packages/react/src/patterns/MultipleChoice/views/Feedback/tokens.ts
similarity index 85%
rename from packages/react/src/components/FeedbackModal/tokens.ts
rename to packages/react/src/patterns/MultipleChoice/views/Feedback/tokens.ts
index 2b41c5d2d..4ed7a10ad 100644
--- a/packages/react/src/components/FeedbackModal/tokens.ts
+++ b/packages/react/src/patterns/MultipleChoice/views/Feedback/tokens.ts
@@ -1,4 +1,4 @@
-export const CSS_NAME = 'nds-feedback-modal';
+export const CSS_NAME = 'nds-mc-feedback';
export const css = {
container: `${CSS_NAME}__container`,
diff --git a/packages/react/src/components/FeedbackModal/types.ts b/packages/react/src/patterns/MultipleChoice/views/Feedback/types.ts
similarity index 71%
rename from packages/react/src/components/FeedbackModal/types.ts
rename to packages/react/src/patterns/MultipleChoice/views/Feedback/types.ts
index 099b5c9c2..6be17121b 100644
--- a/packages/react/src/components/FeedbackModal/types.ts
+++ b/packages/react/src/patterns/MultipleChoice/views/Feedback/types.ts
@@ -1,6 +1,6 @@
-import { ModalProps } from '../Modal';
+import { ModalProps } from '@wwnds/react';
-export type FeedbackModalProps = Omit & {
+export type FeedbackProps = Omit & {
/**
* If the general feedback to give is "correct".
*
diff --git a/packages/react/src/patterns/MultipleChoice/views/Question/AnswerChoice.tsx b/packages/react/src/patterns/MultipleChoice/views/Question/AnswerChoice.tsx
new file mode 100644
index 000000000..684ec3391
--- /dev/null
+++ b/packages/react/src/patterns/MultipleChoice/views/Question/AnswerChoice.tsx
@@ -0,0 +1,28 @@
+import React, { useCallback } from 'react';
+import { Radio } from '../../../../components/Radio';
+import { styles } from './styles';
+import { AnswerChoiceProps } from './types';
+
+export const AnswerChoice = ({
+ label,
+ children,
+ onSelect,
+ index,
+ checked,
+ name,
+ disabled,
+}: AnswerChoiceProps) => {
+ const onChange = useCallback(() => {
+ if (index === undefined || !label || !onSelect) {
+ return;
+ }
+ onSelect({ index, label });
+ }, [onSelect, index, label]);
+
+ return (
+
+ {label}.
+ {children}
+
+ );
+};
diff --git a/packages/react/src/patterns/MultipleChoice/MultipleChoice.tsx b/packages/react/src/patterns/MultipleChoice/views/Question/Question.tsx
similarity index 56%
rename from packages/react/src/patterns/MultipleChoice/MultipleChoice.tsx
rename to packages/react/src/patterns/MultipleChoice/views/Question/Question.tsx
index 3d740e1b3..f1c32cced 100644
--- a/packages/react/src/patterns/MultipleChoice/MultipleChoice.tsx
+++ b/packages/react/src/patterns/MultipleChoice/views/Question/Question.tsx
@@ -1,47 +1,36 @@
import React from 'react';
-import { RadioGroup } from '../../components/Radio';
-import { ResponseIndicator } from '../../components/ResponseIndicator';
+import { RadioGroup } from '../../../../components/Radio';
+import { ResponseIndicator } from '../../../../components/ResponseIndicator';
+import { useId } from '../../../../utilities/id';
import { AnswerChoice } from './AnswerChoice';
-import { Instructions } from './Instructions';
-import { Intro } from './Intro';
-import { Stem } from './Stem';
+import { Instructions } from './QuestionInstructions';
+import { QuestionFraming } from './QuestionFraming';
+import { QuestionStem } from './QuestionStem';
import { styles } from './styles';
-import { LabelType, OnSelectInput } from './types';
+import { QuestionProps } from './types';
import { resolveLabelType } from './utils';
-export interface MultipleChoiceProps {
- stem: string | React.ReactElement;
- intro?: string | React.ReactElement;
- instructions?: string | React.ReactElement;
- choices: string[];
- /**
- * @default 'lower-alpha'
- */
- labelType?: LabelType;
- status: 'correct' | 'incorrect' | 'unanswered';
- onSelect?: (input: OnSelectInput) => void;
- selected?: number;
- // TODO: support styling pieces
-}
-
-export const MultipleChoice = ({
+export const Question = ({
stem,
- intro,
+ framing,
instructions,
- labelType = 'lower-alpha',
+ identifierType = 'lower-alpha',
choices,
status,
onSelect,
selected,
-}: MultipleChoiceProps) => {
- const introElement = typeof intro === 'string' ? {intro} : intro;
- const stemElement = typeof stem === 'string' ? {stem} : stem;
+ readOnly,
+}: QuestionProps) => {
+ const groupName = useId() || '';
+ const framingElement =
+ typeof framing === 'string' ? {framing} : framing;
+ const stemElement = typeof stem === 'string' ? {stem} : stem;
const instructionsElement =
typeof instructions === 'string' ? {instructions} : instructions;
return (
- {introElement}
+ {framingElement}
{stemElement}
{instructionsElement}
@@ -49,7 +38,7 @@ export const MultipleChoice = ({
{choices.map((choice, index) => {
const isCorrect = status === 'correct' && index === selected;
const isIncorrect = status === 'incorrect' && index === selected;
- const label = resolveLabelType(labelType, index);
+ const label = resolveLabelType(identifierType, index);
let feedback: React.ReactNode = null;
if (isCorrect) {
@@ -78,7 +67,14 @@ export const MultipleChoice = ({
{feedback}
diff --git a/packages/react/src/patterns/MultipleChoice/Intro.tsx b/packages/react/src/patterns/MultipleChoice/views/Question/QuestionFraming.tsx
similarity index 57%
rename from packages/react/src/patterns/MultipleChoice/Intro.tsx
rename to packages/react/src/patterns/MultipleChoice/views/Question/QuestionFraming.tsx
index 10d3615a9..cb98fb6d4 100644
--- a/packages/react/src/patterns/MultipleChoice/Intro.tsx
+++ b/packages/react/src/patterns/MultipleChoice/views/Question/QuestionFraming.tsx
@@ -1,6 +1,6 @@
import React from 'react';
import { styles } from './styles';
-export const Intro = ({ children }: React.PropsWithChildren
) => (
+export const QuestionFraming = ({ children }: React.PropsWithChildren) => (
{children}
);
diff --git a/packages/react/src/patterns/MultipleChoice/Instructions.tsx b/packages/react/src/patterns/MultipleChoice/views/Question/QuestionInstructions.tsx
similarity index 100%
rename from packages/react/src/patterns/MultipleChoice/Instructions.tsx
rename to packages/react/src/patterns/MultipleChoice/views/Question/QuestionInstructions.tsx
diff --git a/packages/react/src/patterns/MultipleChoice/Stem.tsx b/packages/react/src/patterns/MultipleChoice/views/Question/QuestionStem.tsx
similarity index 57%
rename from packages/react/src/patterns/MultipleChoice/Stem.tsx
rename to packages/react/src/patterns/MultipleChoice/views/Question/QuestionStem.tsx
index f4e5735f4..88cd47d8b 100644
--- a/packages/react/src/patterns/MultipleChoice/Stem.tsx
+++ b/packages/react/src/patterns/MultipleChoice/views/Question/QuestionStem.tsx
@@ -1,6 +1,6 @@
import React from 'react';
import { styles } from './styles';
-export const Stem = ({ children }: React.PropsWithChildren) => (
+export const QuestionStem = ({ children }: React.PropsWithChildren) => (
{children}
);
diff --git a/packages/react/src/patterns/MultipleChoice/views/Question/index.ts b/packages/react/src/patterns/MultipleChoice/views/Question/index.ts
new file mode 100644
index 000000000..842de2750
--- /dev/null
+++ b/packages/react/src/patterns/MultipleChoice/views/Question/index.ts
@@ -0,0 +1,2 @@
+export * from './Question';
+export type * from './types';
diff --git a/packages/react/src/patterns/MultipleChoice/styles.ts b/packages/react/src/patterns/MultipleChoice/views/Question/styles.ts
similarity index 85%
rename from packages/react/src/patterns/MultipleChoice/styles.ts
rename to packages/react/src/patterns/MultipleChoice/views/Question/styles.ts
index 558e7671b..d67eedebb 100644
--- a/packages/react/src/patterns/MultipleChoice/styles.ts
+++ b/packages/react/src/patterns/MultipleChoice/views/Question/styles.ts
@@ -1,4 +1,4 @@
-const prefix = 'nds-multiple-choice';
+const prefix = 'nds-mc-question';
export const styles = {
stem: `${prefix}__stem`,
diff --git a/packages/react/src/patterns/MultipleChoice/views/Question/types.ts b/packages/react/src/patterns/MultipleChoice/views/Question/types.ts
new file mode 100644
index 000000000..837352077
--- /dev/null
+++ b/packages/react/src/patterns/MultipleChoice/views/Question/types.ts
@@ -0,0 +1,66 @@
+import { RadioProps } from 'packages/react/src/components';
+import { Instructions } from './QuestionInstructions';
+import { QuestionFraming } from './QuestionFraming';
+import { QuestionStem } from './QuestionStem';
+
+/**
+ * The format for the answer choice identifier. A subset of options borrowed from CSS's
+ * [list style type](https://developer.mozilla.org/en-US/docs/Web/CSS/list-style-type#values).
+ */
+export type AnswerChoiceIdentifierType =
+ | 'lower-alpha'
+ | 'upper-alpha'
+ | 'lower-roman'
+ | 'upper-roman'
+ | 'decimal';
+
+export interface QuestionProps {
+ /**
+ * Introductory content that provides additional context for the question.
+ * This can include any type of content, including but not limited to text, images, and video.
+ */
+ framing?: string | React.ReactElement;
+ /** The text-only prompt. Answer choices are responses to the question stem. */
+ stem: string | React.ReactElement;
+ /**
+ * Instructions about the constraints for answering the question. Unlike the
+ * stem and framing, this should not contain subject matter related to the question.
+ */
+ instructions?: string | React.ReactElement;
+ /** The list of potential answers the question. */
+ choices: string[];
+ /**
+ * The type for the letter or number that identifies the choice.
+ * @default 'lower-alpha'
+ */
+ identifierType?: AnswerChoiceIdentifierType;
+ /** The current state of the question. */
+ status: 'correct' | 'incorrect' | 'unanswered';
+ /** Callback function that is called when the user selects a choice. */
+ onSelect?: (choice: ChoiceObject) => void;
+ /** The index of the currently selected answer choice. */
+ selected?: number;
+ /** When in read only, the user can no longer select a new answer. */
+ readOnly?: boolean;
+ // TODO: support styling pieces
+}
+
+type ChoiceObject = {
+ /** The choice's label describes the option. */
+ label: string;
+ /** The choice's position in the list of choices. */
+ index: number;
+};
+
+export interface AnswerChoiceProps extends Pick {
+ /** The choice's label describes the option. */
+ label?: string;
+ /** The choice's position in the list of choices. */
+ index?: number;
+ /** Whether the answer choice is currently selected. */
+ checked?: boolean;
+ /** Callback function that is called when the user selects a choice. */
+ onSelect?: (choice: ChoiceObject) => void;
+ /** The node(s) that will be rendered inside the choice. */
+ children: React.ReactNode;
+}
diff --git a/packages/react/src/patterns/MultipleChoice/utils.ts b/packages/react/src/patterns/MultipleChoice/views/Question/utils.ts
similarity index 63%
rename from packages/react/src/patterns/MultipleChoice/utils.ts
rename to packages/react/src/patterns/MultipleChoice/views/Question/utils.ts
index f9d72db54..3f839cd28 100644
--- a/packages/react/src/patterns/MultipleChoice/utils.ts
+++ b/packages/react/src/patterns/MultipleChoice/views/Question/utils.ts
@@ -1,9 +1,9 @@
-import { LabelType } from './types';
+import { AnswerChoiceIdentifierType } from './types';
const RomanLiterals = ['i', 'ii', 'iii', 'iv', 'v', 'vi', 'vii', 'viii', 'ix', 'x'];
-export function resolveLabelType(labelType: LabelType, index: number) {
- switch (labelType) {
+export function resolveLabelType(identifierType: AnswerChoiceIdentifierType, index: number) {
+ switch (identifierType) {
case 'lower-alpha':
return String.fromCharCode(97 + index);
case 'upper-alpha':
@@ -15,6 +15,6 @@ export function resolveLabelType(labelType: LabelType, index: number) {
case 'decimal':
return String(index + 1);
default:
- throw new Error(`Invalid label type: ${labelType}`);
+ throw new Error(`Invalid label type: ${identifierType}`);
}
}
diff --git a/website/docs/patterns/multiple-choice.mdx b/website/docs/patterns/multiple-choice.mdx
index 60a1d1e12..1c10e80d6 100644
--- a/website/docs/patterns/multiple-choice.mdx
+++ b/website/docs/patterns/multiple-choice.mdx
@@ -5,7 +5,7 @@ beta: true
---
import useBaseUrl from "@docusaurus/useBaseUrl";
-import { Badge, MultipleChoice, FeedbackModal } from "@wwnds/react";
+import { Badge, MultipleChoiceQuestion, MultipleChoiceFeedback } from "@wwnds/react";
import { PropsTable } from "@website/components";
> The multiple choice pattern allows the user to respond to a prompt and then provides feedback about their answer choice.
@@ -133,71 +133,64 @@ A consistent way to reference responses across questions ensures that we don't h
This is especially critical for users who experience difficulty with attention and working memory.
:::
+## React API
-A live demo can be found in the storybook for the Multiple Choice Pattern.
+Our implementation includes components for the [Question view](#question) and the [Feedback view](#feedback), as well as a hook to help manage state across views.
+
+The following code demonstrates one way to compose the components and hook together.
+Head to the Multiple Choice Storybook to view a live example.
```tsx
-import React, { useCallback } from "react";
import {
+ MultipleChoiceQuestion,
+ MultipleChoiceFeedback,
useMultipleChoice,
- MultipleChoice,
- FeedbackModal,
Button,
} from "@wwnds/react";
-const choices = [
- "Jayvon, who had opened a checking account at the branch that same day",
- "Ibrahim, who is taking propranolol to control his blood pressure",
- "Huong, who was born with Urbach-Wiethe syndrome and lacks an amygdala",
-];
-
-export function PatternExample() {
+export function MultipleChoice({ choices }: { choices: string[] }) {
const { questionState, setStatus, modalState } = useMultipleChoice(choices);
const handleSubmit = useCallback(
(event: React.FormEvent) => {
event.preventDefault();
-
- if (questionState.selected === 1) {
- setStatus("correct");
- } else {
- setStatus("incorrect");
- }
+ // check if the user submitted the correct answer or not.
+ // for instance, use setStatus("correct") if they got it right.
},
- [setStatus, questionState.selected]
+ [setStatus, questionState.selected],
);
return (
+ // note that the