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 ( +
+ + +
+ +
+ setModalOpen(false)} + > + Please select an answer to receive feedback. + + + ); +}; + +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}
- + {choice}
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
and -
+ + ); } ``` -### useMultipleChoice Hook +### MultipleChoiceQuestion Component -The `useMultipleChoice` hook is used to manage the state of the multiple choice question. It accepts an array of answer choices and returns the state shared by the `FeedbackModal` and `MultipleChoice` components. +The `MultipleChoiceQuestion` component is used to render the multiple choice question. It accepts the following props: -### MultipleChoice Component + -The `MultipleChoice` component is used to render the multiple choice question. It accepts the following props: +### MultipleChoiceFeedback Component - +The `MultipleChoiceFeedback` component extends our [Modal component](../components/modal) and inherits all of its props except the ones related to the title. -### FeedbackModal Component + -The `FeedbackModal` component is used to render the feedback modal. It accepts the following props: +### useMultipleChoice Hook - +The `useMultipleChoice` hook is used to manage the state of the multiple choice question. +It accepts an array of answer choices and returns the state shared by the `FeedbackModal` and `MultipleChoice` components.