Skip to content

Commit

Permalink
Merge pull request #355 from wwnorton/multiple-choice-pattern
Browse files Browse the repository at this point in the history
Multiple choice pattern
  • Loading branch information
cafrias authored May 8, 2024
2 parents f9f0427 + 3d19cdd commit c613bab
Show file tree
Hide file tree
Showing 30 changed files with 639 additions and 7 deletions.
8 changes: 7 additions & 1 deletion .cspell.json
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@
"microbundle",
"mixins",
"multiselect",
"multiplechoice",
"Myxococcus",
"Patil",
"popperjs",
Expand Down Expand Up @@ -104,6 +105,11 @@
"wwnds",
"wwnorton",
"Yamanishi",
"zindex"
"zindex",
"Jayvon",
"Ibrahim",
"Huong",
"Urbach",
"Wiethe"
]
}
1 change: 1 addition & 0 deletions packages/core/src/_system.scss
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@
@forward 'components/link' as link-*;
@forward 'components/listbox' as listbox-*;
@forward 'components/modal' as modal-*;
@forward 'components/multiple-choice' as multiple-choice-*;
@forward 'components/popover' as popover-*;
@forward 'components/popper' as popper-*;
@forward 'components/progressbar' as progressbar-*;
Expand Down
6 changes: 3 additions & 3 deletions packages/core/src/components/field/index.scss
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@
@mixin info {
@include type.ui-base;

:last-child {
& > *:last-child {
margin-bottom: spacing.spacer('ui-inner');
}
}
Expand Down Expand Up @@ -87,7 +87,7 @@
display: flex;
padding: var(--nds-field-padding-y) 0;

> * {
>* {
margin-right: var(--nds-field-offset-x);
}

Expand All @@ -100,7 +100,7 @@
margin-right: unset;
}

> .nds-field__input {
>.nds-field__input {
// still position it in the center of the visual control so that form
// validator tooltips will point at the right thing
top: calc(var(--nds-font-size-base) * #{type.lh('ui')} / 2);
Expand Down
48 changes: 48 additions & 0 deletions packages/core/src/components/multiple-choice/index.scss
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
@use '../../util';

@mixin style {
@include util.declare('multiple-choice') {
.nds-multiple-choice {
&__intro {
margin-bottom: 0.25rem;
}

&__stem {
margin-bottom: 0.25rem;
font-weight: bold;
}

&__instructions {
margin-bottom: 2rem;
font-size: var(--nds-font-size-xs);
}

&__choice {
& > input,
& > label {
position: absolute;

// FIXME: When adding a div as a children the hidden controls appear
// we need to fix this in the ChoiceField component
display: block;
width: 0;
height: 0;
visibility: hidden;
}

& .nds-field__label {
display: flex;
gap: 1rem;
}
}

&__feedback {
min-width: 5rem;
}

&__choice-label {
font-weight: bold;
}
}
}
}
1 change: 1 addition & 0 deletions packages/core/src/index.scss
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,7 @@
@include nds.link-style;
@include nds.listbox-style;
@include nds.modal-style;
@include nds.multiple-choice-style;
@include nds.popover-style;
@include nds.popper-style;
@include nds.progressbar-style;
Expand Down
1 change: 1 addition & 0 deletions packages/react/src/components/FeedbackModal/index.ts
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
export { FeedbackModal } from './FeedbackModal';
export type { FeedbackModalProps } from './types';
Original file line number Diff line number Diff line change
Expand Up @@ -18,13 +18,19 @@ export const ResponseIndicator: React.FC<ResponseIndicatorProps> = ({
label,
withIcon = true,
placementIcon = 'left',
className,
}) => {
const uniqueId = useId();
const id = idProp || uniqueId;

const containerClassName = classNames(BASE_NAME, `${BASE_NAME}--${variant}`, {
[`${BASE_NAME}--${placementIcon}`]: withIcon,
});
const containerClassName = classNames(
BASE_NAME,
`${BASE_NAME}--${variant}`,
{
[`${BASE_NAME}--${placementIcon}`]: withIcon,
},
className,
);

return (
<div className={containerClassName}>
Expand Down
2 changes: 2 additions & 0 deletions packages/react/src/components/ResponseIndicator/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,4 +35,6 @@ export interface ResponseIndicatorProps {
* will override the default label
*/
label?: string;

className?: string;
}
1 change: 1 addition & 0 deletions packages/react/src/components/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ export * from './Checkbox';
export * from './ChoiceField';
export * from './Disclosure';
export * from './Dropdown';
export * from './FeedbackModal';
export * from './Field';
export * from './Icon';
export * from './Link';
Expand Down
2 changes: 2 additions & 0 deletions packages/react/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,3 +6,5 @@ export * from './providers';

// Public utilities
export * from './utilities';

export * from './patterns';
28 changes: 28 additions & 0 deletions packages/react/src/patterns/MultipleChoice/AnswerChoice.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
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 (
<Radio onChange={onChange} checked={checked}>
<span className={styles.choiceLabel}>{label}. </span>
<span>{children}</span>
</Radio>
);
};
6 changes: 6 additions & 0 deletions packages/react/src/patterns/MultipleChoice/Instructions.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import React from 'react';
import { styles } from './styles';

export const Instructions = ({ children }: React.PropsWithChildren<unknown>) => (
<div className={styles.instructions}>{children}</div>
);
6 changes: 6 additions & 0 deletions packages/react/src/patterns/MultipleChoice/Intro.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import React from 'react';
import { styles } from './styles';

export const Intro = ({ children }: React.PropsWithChildren<unknown>) => (
<div className={styles.intro}>{children}</div>
);
92 changes: 92 additions & 0 deletions packages/react/src/patterns/MultipleChoice/MultipleChoice.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
import React from 'react';
import { RadioGroup } from '../../components/Radio';
import { ResponseIndicator } from '../../components/ResponseIndicator';
import { AnswerChoice } from './AnswerChoice';
import { Instructions } from './Instructions';
import { Intro } from './Intro';
import { Stem } from './Stem';
import { styles } from './styles';
import { LabelType, OnSelectInput } from './types';
import { resolveLabelType } from './utils';

export interface MultipleChoiceProps {
stem: string | React.ReactElement<void, typeof Stem>;
intro?: string | React.ReactElement<void, typeof Intro>;
instructions?: string | React.ReactElement<void, typeof Instructions>;
choices: string[];
/**
* @default 'lower-alpha'
*/
labelType?: LabelType;
status: 'correct' | 'incorrect' | 'unanswered';
onSelect?: (input: OnSelectInput) => void;
selected?: number;
// TODO: support styling pieces
}

export const MultipleChoice = ({
stem,
intro,
instructions,
labelType = 'lower-alpha',
choices,
status,
onSelect,
selected,
}: MultipleChoiceProps) => {
const introElement = typeof intro === 'string' ? <Intro>{intro}</Intro> : intro;
const stemElement = typeof stem === 'string' ? <Stem>{stem}</Stem> : stem;
const instructionsElement =
typeof instructions === 'string' ? <Instructions>{instructions}</Instructions> : instructions;

return (
<div>
{introElement}
{stemElement}
{instructionsElement}
<div>
<RadioGroup label={undefined}>
{choices.map((choice, index) => {
const isCorrect = status === 'correct' && index === selected;
const isIncorrect = status === 'incorrect' && index === selected;
const label = resolveLabelType(labelType, index);

let feedback: React.ReactNode = null;
if (isCorrect) {
feedback = (
<ResponseIndicator className={styles.feedback} withIcon={false} variant="correct" />
);
} else if (isIncorrect) {
feedback = (
<ResponseIndicator
className={styles.feedback}
withIcon={false}
variant="incorrect"
/>
);
} else {
feedback = <div className={styles.feedback} />;
}

const checked = index === selected;

// TODO: think a way to allow control of response indicator layout
//
// TODO: use grid to solve issues with incorrect response
// indicator
return (
<div key={label} className={styles.choice}>
{feedback}
<div>
<AnswerChoice label={label} checked={checked} value={index} onSelect={onSelect}>
{choice}
</AnswerChoice>
</div>
</div>
);
})}
</RadioGroup>
</div>
</div>
);
};
43 changes: 43 additions & 0 deletions packages/react/src/patterns/MultipleChoice/PatternExample.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
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<HTMLFormElement>) => {
event.preventDefault();

if (questionState.selected === 1) {
setStatus('correct');
} else {
setStatus('incorrect');
}
},
[setStatus, questionState.selected],
);

return (
<form onSubmit={handleSubmit}>
<MultipleChoice
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."
{...questionState}
/>
<FeedbackModal {...modalState} />
<div>
<Button type="submit" variant="solid" color="primary">
Submit
</Button>
</div>
</form>
);
};
6 changes: 6 additions & 0 deletions packages/react/src/patterns/MultipleChoice/Stem.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import React from 'react';
import { styles } from './styles';

export const Stem = ({ children }: React.PropsWithChildren<unknown>) => (
<div className={styles.stem}>{children}</div>
);
Loading

0 comments on commit c613bab

Please sign in to comment.