@khanacademy/perseus 0.4.2 → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. package/.eslintrc.js +11 -0
  2. package/CHANGELOG.md +47 -0
  3. package/dist/es/index.js +1078 -1299
  4. package/dist/es/index.js.map +1 -1
  5. package/dist/index.js +1077 -1304
  6. package/dist/index.js.map +1 -1
  7. package/dist/strings.js +0 -4
  8. package/package.json +9 -8
  9. package/src/multi-items/__tests__/__snapshots__/multi-renderer_test.js.snap +110 -110
  10. package/src/perseus-api.jsx +10 -36
  11. package/src/perseus-types.js +0 -138
  12. package/src/styles/constants.js +0 -2
  13. package/src/styles/variables.less +0 -2
  14. package/src/types.js +18 -35
  15. package/src/util.js +0 -6
  16. package/src/version.js +1 -1
  17. package/src/widgets/__testdata__/base-radio_testdata.js +22 -0
  18. package/src/widgets/__tests__/__snapshots__/group_test.jsx.snap +110 -110
  19. package/src/widgets/__tests__/__snapshots__/radio_test.jsx.snap +1136 -1160
  20. package/src/widgets/__tests__/radio/base-radio_test.jsx +243 -41
  21. package/src/widgets/__tests__/radio/choice-icon_test.jsx +67 -0
  22. package/src/widgets/__tests__/radio/choice_test.jsx +201 -0
  23. package/src/widgets/__tests__/radio/focus-ring_test.jsx +23 -0
  24. package/src/widgets/__tests__/radio/option-status_test.jsx +65 -0
  25. package/src/widgets/passage/__tests__/passage-markdown_test.jsx +0 -9
  26. package/src/widgets/passage/get-line-height-for-node.js +4 -5
  27. package/src/widgets/passage/passage-markdown.jsx +176 -120
  28. package/src/widgets/passage.jsx +48 -46
  29. package/src/widgets/radio/__stories__/base-radio.stories.jsx +164 -17
  30. package/src/widgets/radio/__stories__/choice-icon.stories.jsx +202 -111
  31. package/src/widgets/radio/__stories__/choice-none-above.stories.jsx +21 -6
  32. package/src/widgets/radio/__stories__/choice.stories.jsx +138 -0
  33. package/src/widgets/radio/__stories__/focus-ring.stories.jsx +29 -23
  34. package/src/widgets/radio/__stories__/option-status.stories.jsx +93 -99
  35. package/src/widgets/radio/base-radio.jsx +483 -556
  36. package/src/widgets/radio/choice-icon/choice-icon.jsx +155 -0
  37. package/src/widgets/radio/choice-icon/cross-out-line.jsx +55 -0
  38. package/src/widgets/radio/choice-icon/library-choice-icon.jsx +219 -0
  39. package/src/widgets/radio/choice-icon/sat-choice-icon.jsx +129 -0
  40. package/src/widgets/radio/choice-icon/shared-styles.js +17 -0
  41. package/src/widgets/radio/choice-none-above.jsx +49 -40
  42. package/src/widgets/radio/choice.jsx +282 -353
  43. package/src/widgets/radio/choice.jsx.fixture.js +0 -18
  44. package/src/widgets/radio/focus-ring.jsx +29 -22
  45. package/src/widgets/radio/option-status.jsx +58 -36
  46. package/src/widgets/radio/widget.jsx +181 -136
  47. package/src/widgets/radio.jsx +16 -17
  48. package/src/widgets/radio/__stories__/cross-out-button.stories.jsx +0 -64
  49. package/src/widgets/radio/choice-icon.jsx +0 -469
  50. package/src/widgets/radio/cross-out-button.jsx +0 -134
@@ -2,12 +2,11 @@
2
2
  import * as i18n from "@khanacademy/wonder-blocks-i18n";
3
3
  import {StyleSheet, css} from "aphrodite";
4
4
  import classNames from "classnames";
5
- import PropTypes from "prop-types";
6
5
  import * as React from "react";
6
+ import {useRef, useEffect} from "react";
7
7
  import ReactDOM from "react-dom";
8
8
  import _ from "underscore";
9
9
 
10
- import {Errors as PerseusErrors, Log} from "../../logging/log.js";
11
10
  import {ClassNames as ApiClassNames} from "../../perseus-api.jsx";
12
11
  import * as styleConstants from "../../styles/constants.js";
13
12
  import mediaQueries from "../../styles/media-queries.js";
@@ -18,259 +17,95 @@ import {scrollElementIntoView} from "../../util/scroll-utils.js";
18
17
  import ChoiceNoneAbove from "./choice-none-above.jsx";
19
18
  import Choice from "./choice.jsx";
20
19
 
20
+ import type {PerseusRadioWidgetOptions} from "../../perseus-types";
21
+ import type {APIOptions} from "../../types.js";
21
22
  import type {StyleDeclaration} from "aphrodite";
22
23
 
23
24
  const {captureScratchpadTouchStart} = Util;
24
25
 
25
- const ChoicesType = PropTypes.arrayOf(
26
- PropTypes.shape({
27
- // Indicates whether this choice is checked.
28
- checked: PropTypes.bool,
29
-
30
- // Indicates whether the user has "crossed out" this choice, meaning
31
- // that they don't think it's correct. This value does not affect
32
- // scoring or other behavior; it's just a note for the user's
33
- // reference.
34
- crossedOut: PropTypes.bool,
35
-
36
- content: PropTypes.node,
37
- rationale: PropTypes.node,
38
- hasRationale: PropTypes.bool,
39
- showRationale: PropTypes.bool,
40
- showCorrectness: PropTypes.bool,
41
- correct: PropTypes.bool,
42
- originalIndex: PropTypes.number,
43
- isNoneOfTheAbove: PropTypes.bool,
44
- }),
45
- );
46
-
47
- const radioBorderColor = styleConstants.radioBorderColor;
48
-
49
- class BaseRadio extends React.Component<$FlowFixMe, $FlowFixMe> {
50
- static propTypes = {
51
- apiOptions: PropTypes.shape({
52
- readOnly: PropTypes.bool,
53
- satStyling: PropTypes.bool,
54
- isMobile: PropTypes.bool,
55
- styling: PropTypes.shape({
56
- radioStyleVersion: PropTypes.oneOf(["intermediate", "final"]),
57
- }),
58
- canScrollPage: PropTypes.bool,
59
- }),
60
- choices: ChoicesType,
61
- deselectEnabled: PropTypes.bool,
62
- editMode: PropTypes.bool,
63
- labelWrap: PropTypes.bool,
64
- countChoices: PropTypes.bool,
65
- numCorrect: PropTypes.number,
66
- multipleSelect: PropTypes.bool,
67
- reviewModeRubric: PropTypes.shape({
68
- choices: ChoicesType,
69
- }),
70
-
71
- // A callback indicating that this choice has changed. Its argument is
72
- // an object with two keys: `checked` and `crossedOut`. Each contains
73
- // an array of boolean values, specifying the new checked and
74
- // crossed-out value of each choice.
75
- onChange: PropTypes.func,
76
-
77
- // Whether this widget was the most recently used widget in this
78
- // Renderer. Determines whether we'll auto-scroll the page upon
79
- // entering review mode.
80
- isLastUsedWidget: PropTypes.bool,
81
- };
82
-
83
- static styles: StyleDeclaration = StyleSheet.create({
84
- // eslint-disable-next-line react-native/no-unused-styles
85
- instructions: {
86
- display: "block",
87
- color: styleConstants.gray17,
88
- fontSize: 14,
89
- lineHeight: 1.25,
90
- fontStyle: "normal",
91
- fontWeight: "bold",
92
- marginBottom: 16,
93
- },
94
-
95
- // eslint-disable-next-line react-native/no-unused-styles
96
- instructionsMobile: {
97
- fontSize: 18,
98
- [mediaQueries.smOrSmaller]: {
99
- fontSize: 16,
100
- },
101
- // TODO(emily): We want this to match choice text, which turns
102
- // to 20px at min-width 1200px, but this media query is
103
- // min-width 1280px because our media queries don't exactly
104
- // match pure. Make those match up.
105
- [mediaQueries.xl]: {
106
- fontSize: 20,
107
- },
108
- },
109
-
110
- // eslint-disable-next-line react-native/no-unused-styles
111
- radio: {
112
- // Avoid centering
113
- width: "100%",
114
- },
115
-
116
- // eslint-disable-next-line react-native/no-unused-styles
117
- responsiveRadioContainer: {
118
- borderBottom: `1px solid ${radioBorderColor}`,
119
- borderTop: `1px solid ${radioBorderColor}`,
120
- width: "auto",
121
- [mediaQueries.smOrSmaller]: {
122
- marginLeft: styleConstants.negativePhoneMargin,
123
- marginRight: styleConstants.negativePhoneMargin,
124
- },
125
- },
126
-
127
- // eslint-disable-next-line react-native/no-unused-styles
128
- radioContainerFirstHighlighted: {
129
- borderTop: `1px solid rgba(0, 0, 0, 0)`,
130
- },
131
-
132
- // eslint-disable-next-line react-native/no-unused-styles
133
- radioContainerLastHighlighted: {
134
- borderBottom: `1px solid rgba(0, 0, 0, 0)`,
135
- },
136
-
137
- // eslint-disable-next-line react-native/no-unused-styles
138
- satRadio: {
139
- background: "none",
140
- marginLeft: 0,
141
- userSelect: "none",
142
- },
143
-
144
- // eslint-disable-next-line react-native/no-unused-styles
145
- satRadioOption: {
146
- margin: 0,
147
- padding: 0,
148
- borderBottom: `1px solid #ccc`,
149
- ":first-child": {
150
- borderTop: `1px solid #ccc`,
151
- },
152
- },
153
-
154
- // eslint-disable-next-line react-native/no-unused-styles
155
- satRadioOptionCorrect: {
156
- borderBottomColor: styleConstants.satCorrectBorderColor,
157
- ":first-child": {
158
- borderTopColor: styleConstants.satCorrectBorderColor,
159
- },
160
- },
161
-
162
- // eslint-disable-next-line react-native/no-unused-styles
163
- satRadioOptionIncorrect: {
164
- borderBottomColor: styleConstants.satIncorrectBorderColor,
165
- ":first-child": {
166
- borderTopColor: styleConstants.satIncorrectBorderColor,
167
- },
168
- },
169
-
170
- // eslint-disable-next-line react-native/no-unused-styles
171
- satRadioOptionNextCorrect: {
172
- borderBottomColor: styleConstants.satCorrectBorderColor,
173
- },
174
-
175
- // eslint-disable-next-line react-native/no-unused-styles
176
- satRadioOptionNextIncorrect: {
177
- borderBottomColor: styleConstants.satIncorrectBorderColor,
178
- },
179
-
180
- // eslint-disable-next-line react-native/no-unused-styles
181
- satReviewRadioOption: {
182
- pointerEvents: "none",
183
- },
184
-
185
- // eslint-disable-next-line react-native/no-unused-styles
186
- item: {
187
- marginLeft: 20,
188
- },
189
-
190
- // eslint-disable-next-line react-native/no-unused-styles
191
- inlineItem: {
192
- display: "inline-block",
193
- paddingLeft: 20,
194
- verticalAlign: "middle",
195
- // See http://stackoverflow.com/q/8120466 for explanation of
196
- // why vertical align property is needed
197
- },
198
-
199
- // eslint-disable-next-line react-native/no-unused-styles
200
- responsiveItem: {
201
- marginLeft: 0,
202
- padding: 0,
203
-
204
- ":not(:last-child)": {
205
- borderBottom: `1px solid ${radioBorderColor}`,
206
- },
207
- },
208
-
209
- // eslint-disable-next-line react-native/no-unused-styles
210
- selectedItem: {
211
- background: "white",
212
- },
213
-
214
- // eslint-disable-next-line react-native/no-unused-styles
215
- aboveBackdrop: {
216
- position: "relative",
217
- // HACK(emily): We want selected choices to show up above our
218
- // exercise backdrop, but below the exercise footer and
219
- // "feedback popover" that shows up. This z-index is carefully
220
- // coordinated between here and webapp. :(
221
- zIndex: 1062,
222
- },
223
-
224
- // eslint-disable-next-line react-native/no-unused-styles
225
- aboveBackdropMobile: {
226
- boxShadow:
227
- "0 0 4px 0 rgba(0, 0, 0, 0.2)," +
228
- "0 0 2px 0 rgba(0, 0, 0, 0.1)",
229
-
230
- ":not(:last-child)": {
231
- borderBottom: `1px solid rgba(0, 0, 0, 0)`,
232
- },
233
- },
234
-
235
- // eslint-disable-next-line react-native/no-unused-styles
236
- nextHighlighted: {
237
- ":not(:last-child)": {
238
- borderBottom: `1px solid rgba(0, 0, 0, 0)`,
239
- },
240
- },
241
-
242
- // eslint-disable-next-line react-native/no-unused-styles
243
- responsiveContainer: {
244
- overflow: "auto",
245
- marginLeft: styleConstants.negativePhoneMargin,
246
- marginRight: styleConstants.negativePhoneMargin,
247
- paddingLeft: styleConstants.phoneMargin,
248
- // paddingRight is handled by responsiveFieldset
249
- },
250
-
251
- // eslint-disable-next-line react-native/no-unused-styles
252
- responsiveFieldset: {
253
- paddingRight: styleConstants.phoneMargin,
254
- },
255
- });
256
-
257
- static defaultProps: $FlowFixMe = {
258
- editMode: false,
259
- };
260
-
261
- state: $FlowFixMe = {
262
- // TODO(mdr): This keeps the ID stable across re-renders on the
263
- // same machine, but, at time of writing, the server's state
264
- // isn't rehydrated to the client during SSR, so the server and
265
- // client will generate different IDs and cause a mismatch
266
- // during SSR :(
267
- radioGroupName: _.uniqueId("perseus_radio_"),
268
- };
269
-
270
- componentDidUpdate(prevProps: $FlowFixMe) {
271
- const {apiOptions, choices, isLastUsedWidget, reviewModeRubric} =
272
- this.props;
26
+ // exported for tests
27
+ export type ChoiceType = {|
28
+ checked: boolean,
29
+ crossedOut: boolean,
30
+ content: React.Node,
31
+ rationale: React.Node,
32
+ hasRationale: boolean,
33
+ showRationale: boolean,
34
+ showCorrectness: boolean,
35
+ correct: boolean,
36
+ isNoneOfTheAbove: boolean,
37
+ highlighted: boolean,
38
+ previouslyAnswered: boolean,
39
+ revealNoneOfTheAbove: boolean,
40
+ disabled: boolean,
41
+ |};
42
+
43
+ export type FocusFunction = (choiceIndex: ?number) => boolean;
44
+
45
+ type Props = {|
46
+ apiOptions: APIOptions,
47
+ choices: $ReadOnlyArray<ChoiceType>,
48
+ deselectEnabled?: boolean,
49
+ editMode: boolean,
50
+ labelWrap: boolean,
51
+ countChoices: ?boolean,
52
+ numCorrect: number,
53
+ multipleSelect: boolean,
54
+ // the logic checks whether this exists,
55
+ // so it must be optional
56
+ reviewModeRubric?: PerseusRadioWidgetOptions,
57
+
58
+ // A callback indicating that this choice has changed. Its argument is
59
+ // an object with two keys: `checked` and `crossedOut`. Each contains
60
+ // an array of boolean values, specifying the new checked and
61
+ // crossed-out value of each choice.
62
+ onChange: (newValues: {
63
+ checked: $ReadOnlyArray<boolean>,
64
+ crossedOut: $ReadOnlyArray<boolean>,
65
+ }) => void,
66
+ registerFocusFunction?: (FocusFunction) => void,
67
+
68
+ // Whether this widget was the most recently used widget in this
69
+ // Renderer. Determines whether we'll auto-scroll the page upon
70
+ // entering review mode.
71
+ isLastUsedWidget?: boolean,
72
+ |};
73
+
74
+ function getInstructionsText(
75
+ multipleSelect: boolean,
76
+ countChoices: ?boolean,
77
+ numCorrect: number,
78
+ ): string {
79
+ if (multipleSelect) {
80
+ if (countChoices) {
81
+ return i18n._("Choose %(numCorrect)s answers:", {
82
+ numCorrect: numCorrect,
83
+ });
84
+ }
85
+ return i18n._("Choose all answers that apply:");
86
+ }
87
+ return i18n._("Choose 1 answer:");
88
+ }
273
89
 
90
+ function BaseRadio(props: Props): React.Node {
91
+ const {
92
+ apiOptions,
93
+ reviewModeRubric,
94
+ choices,
95
+ editMode,
96
+ multipleSelect,
97
+ labelWrap,
98
+ countChoices,
99
+ numCorrect,
100
+ isLastUsedWidget,
101
+ registerFocusFunction,
102
+ } = props;
103
+
104
+ // useEffect doesn't have previous props
105
+ const prevReviewModeRubric = useRef();
106
+ const choiceRefs = useRef([]);
107
+
108
+ useEffect(() => {
274
109
  // Switching into review mode can sometimes cause the selected answer
275
110
  // to scroll out of view - for example, when we reveal all those tall
276
111
  // rationales. This can be disorienting for the user.
@@ -288,18 +123,24 @@ class BaseRadio extends React.Component<$FlowFixMe, $FlowFixMe> {
288
123
  apiOptions.canScrollPage &&
289
124
  isLastUsedWidget &&
290
125
  reviewModeRubric &&
291
- !prevProps.reviewModeRubric
126
+ !prevReviewModeRubric.current
292
127
  ) {
293
128
  const checkedIndex = choices.findIndex((c) => c.checked);
294
129
  if (checkedIndex >= 0) {
295
- const checkedNode: $FlowFixMe = ReactDOM.findDOMNode(
296
- // eslint-disable-next-line react/no-string-refs
297
- this.refs["radio" + checkedIndex],
298
- );
299
- scrollElementIntoView(checkedNode);
130
+ const ref = choiceRefs.current[checkedIndex];
131
+ // note(matthew): we know this is only getting passed
132
+ // to a WB Clickable button, so we force it to be of
133
+ // type HTMLButtonElement
134
+ const anyNode = (ReactDOM.findDOMNode(ref.current): any);
135
+ const buttonNode = (anyNode: ?HTMLButtonElement);
136
+ if (buttonNode) {
137
+ scrollElementIntoView(buttonNode);
138
+ }
300
139
  }
301
140
  }
302
- }
141
+
142
+ prevReviewModeRubric.current = reviewModeRubric;
143
+ }, [apiOptions, choices, isLastUsedWidget, reviewModeRubric]);
303
144
 
304
145
  // When a particular choice's `onChange` handler is called, indicating a
305
146
  // change in a single choice's values, we need to call our `onChange`
@@ -310,333 +151,419 @@ class BaseRadio extends React.Component<$FlowFixMe, $FlowFixMe> {
310
151
  // will usually cause the old answer to become unchecked.)
311
152
  //
312
153
  // So, given the new values for a particular choice, compute the new values
313
- // for all choices, and pass them to `this.props.onChange`.
154
+ // for all choices, and pass them to `onChange`.
314
155
  //
315
156
  // `newValues` is an object with two keys: `checked` and `crossedOut`. Each
316
157
  // contains a boolean value specifying the new checked and crossed-out
317
158
  // value of this choice.
318
- updateChoice: (number, $FlowFixMe) => void = (choiceIndex, newValues) => {
159
+ function updateChoice(
160
+ choiceIndex: number,
161
+ newValues: $ReadOnly<{checked: boolean, crossedOut: boolean}>,
162
+ ): void {
163
+ const {multipleSelect, choices, onChange} = props;
164
+
319
165
  // Get the baseline `checked` values. If we're checking a new answer
320
166
  // and multiple-select is not on, we should clear all choices to be
321
167
  // unchecked. Otherwise, we should copy the old checked values.
322
168
  let newCheckedList;
323
- if (newValues.checked && !this.props.multipleSelect) {
324
- newCheckedList = this.props.choices.map((_) => false);
169
+ if (newValues.checked && !multipleSelect) {
170
+ newCheckedList = choices.map((_) => false);
325
171
  } else {
326
- newCheckedList = this.props.choices.map((c) => c.checked);
172
+ newCheckedList = choices.map((c) => c.checked);
327
173
  }
328
174
 
329
175
  // Get the baseline `crossedOut` values.
330
- const newCrossedOutList = this.props.choices.map((c) => c.crossedOut);
176
+ const newCrossedOutList = choices.map((c) => c.crossedOut);
331
177
 
332
178
  // Update this choice's `checked` and `crossedOut` values.
333
179
  newCheckedList[choiceIndex] = newValues.checked;
334
180
  newCrossedOutList[choiceIndex] = newValues.crossedOut;
335
181
 
336
- this.props.onChange({
182
+ onChange({
337
183
  checked: newCheckedList,
338
184
  crossedOut: newCrossedOutList,
339
185
  });
340
- };
186
+ }
341
187
 
342
- focus: (number) => boolean = (i) => {
343
- // $FlowFixMe[incompatible-use]
344
- // $FlowFixMe[prop-missing]
345
- ReactDOM.findDOMNode(this.refs["radio" + (i || 0)]).focus(); // eslint-disable-line react/no-string-refs
346
- return true;
347
- };
348
-
349
- getInstructionsText: () => string = () => {
350
- if (this.props.multipleSelect) {
351
- if (this.props.countChoices) {
352
- return i18n._("Choose %(numCorrect)s answers:", {
353
- numCorrect: this.props.numCorrect,
354
- });
355
- }
356
- return i18n._("Choose all answers that apply:");
357
- }
358
- return i18n._("Choose 1 answer:");
359
- };
360
-
361
- deselectEnabled: () => boolean = () => {
362
- // We want to force enable deselect on mobile.
363
- return this.props.apiOptions.isMobile || this.props.deselectEnabled;
364
- };
365
-
366
- /**
367
- * Find the choice at the given index (wrapped, if necessary), focus it,
368
- * uncheck the old choice, and check the new choice (unless it's crossed
369
- * out, in which case we only uncheck the old choice).
370
- *
371
- * This is very similar to standard up/down arrow behavior, except that we
372
- * don't select crossed-out choices.
373
- *
374
- * Handles `goToPrevChoice` and `goToNextChoice` calls from our children.
375
- */
376
- goToChoice: (number) => void = (newChoiceIndex) => {
377
- const numChoices = this.props.choices.length;
378
-
379
- // Wrap the newChoiceIndex around the start/end, if necessary.
380
- if (newChoiceIndex < 0) {
381
- newChoiceIndex += numChoices;
382
- }
383
- if (newChoiceIndex >= numChoices) {
384
- newChoiceIndex -= numChoices;
188
+ // register a callback with the parent that allows
189
+ // the parent to focus an individual choice
190
+ registerFocusFunction?.((choiceIndex) => {
191
+ const ref = choiceRefs.current[choiceIndex || 0];
192
+ // note(matthew): we know this is only getting passed
193
+ // to a WB Clickable button, so we force it to be of
194
+ // type HTMLButtonElement
195
+ const anyNode = (ReactDOM.findDOMNode(ref.current): any);
196
+ const buttonNode = (anyNode: ?HTMLButtonElement);
197
+
198
+ if (buttonNode) {
199
+ buttonNode.focus();
200
+ } else {
201
+ return false;
385
202
  }
203
+ return true;
204
+ });
386
205
 
387
- // Focus the new choice's input element.
388
- // eslint-disable-next-line react/no-string-refs
389
- const choiceInstance = this.refs[`radio${newChoiceIndex}`];
390
- if (!choiceInstance) {
391
- Log.error(
392
- `found no choice at index ${newChoiceIndex}, even after ` +
393
- `wrapping it to be within bounds`,
394
- PerseusErrors.Internal,
395
- {loggedMetadata: {choices: JSON.stringify(this.props.choices)}},
396
- );
397
- return;
398
- }
399
- choiceInstance.focusInput();
206
+ // some commonly used shorthands
207
+ const reviewMode = !!reviewModeRubric;
208
+ const sat = apiOptions.satStyling;
209
+ const isMobile = apiOptions.isMobile;
210
+
211
+ const firstChoiceHighlighted = choices[0].highlighted;
212
+ const lastChoiceHighlighted = choices[choices.length - 1].highlighted;
213
+
214
+ const className: $ReadOnlyArray<string> = classNames(
215
+ "perseus-widget-radio",
216
+ !editMode && "perseus-rendered-radio",
217
+ css(
218
+ styles.radio,
219
+ // SAT doesn't use the "responsive styling" as it conflicts
220
+ // with their custom theming.
221
+ !sat && styles.responsiveRadioContainer,
222
+ !sat &&
223
+ firstChoiceHighlighted &&
224
+ isMobile &&
225
+ styles.radioContainerFirstHighlighted,
226
+ !sat &&
227
+ lastChoiceHighlighted &&
228
+ isMobile &&
229
+ styles.radioContainerLastHighlighted,
230
+ sat && styles.satRadio,
231
+ ),
232
+ );
233
+
234
+ const instructionsClassName: $ReadOnlyArray<string> = classNames(
235
+ "instructions",
236
+ css(styles.instructions, isMobile && styles.instructionsMobile),
237
+ );
238
+ const instructions = getInstructionsText(
239
+ multipleSelect,
240
+ countChoices,
241
+ numCorrect,
242
+ );
243
+ const shouldShowInstructions = !sat;
244
+
245
+ const responsiveClassName = css(styles.responsiveFieldset);
246
+ const fieldset = (
247
+ <fieldset
248
+ className={`perseus-widget-radio-fieldset ${responsiveClassName}`}
249
+ >
250
+ <legend className="perseus-sr-only">{instructions}</legend>
251
+ {shouldShowInstructions && (
252
+ <div className={instructionsClassName}>{instructions}</div>
253
+ )}
254
+ <ul className={className} style={{listStyle: "none"}}>
255
+ {choices.map(function (choice, i) {
256
+ let Element = Choice;
257
+ const ref = React.createRef();
258
+ choiceRefs.current[i] = ref;
259
+ const elementProps = {
260
+ apiOptions: apiOptions,
261
+ multipleSelect: multipleSelect,
262
+ checked: choice.checked,
263
+ crossedOut: choice.crossedOut,
264
+ previouslyAnswered: choice.previouslyAnswered,
265
+ reviewMode,
266
+ correct: choice.correct,
267
+ rationale: choice.rationale,
268
+ content: choice.content,
269
+ disabled: apiOptions.readOnly || choice.disabled,
270
+ showCorrectness: reviewMode || !!choice.showCorrectness,
271
+ showRationale:
272
+ choice.hasRationale &&
273
+ (reviewMode || choice.showRationale),
274
+ pos: i,
275
+ onChange: (newValues) => {
276
+ updateChoice(i, newValues);
277
+ },
278
+ ref,
279
+ };
280
+
281
+ if (choice.isNoneOfTheAbove) {
282
+ Element = ChoiceNoneAbove;
283
+ _.extend(elementProps, {
284
+ showContent: choice.revealNoneOfTheAbove,
285
+ });
286
+ }
287
+
288
+ const nextChoice = choices[i + 1];
289
+ const nextChoiceHighlighted =
290
+ !!nextChoice && nextChoice.highlighted;
291
+
292
+ const aphroditeClassName = (checked) => {
293
+ // Whether or not to show correctness borders
294
+ // for this choice and the next choice.
295
+ const satShowCorrectness = sat && reviewMode && checked;
296
+ const satShowCorrectnessNext =
297
+ sat &&
298
+ reviewMode &&
299
+ nextChoice &&
300
+ nextChoice.checked;
301
+
302
+ return css(
303
+ sharedStyles.aboveScratchpad,
304
+ styles.item,
305
+ !sat && styles.responsiveItem,
306
+ !sat && checked && styles.selectedItem,
307
+ !sat &&
308
+ checked &&
309
+ choice.highlighted &&
310
+ styles.aboveBackdrop,
311
+ !sat &&
312
+ checked &&
313
+ choice.highlighted &&
314
+ apiOptions.isMobile &&
315
+ styles.aboveBackdropMobile,
316
+ !sat &&
317
+ nextChoiceHighlighted &&
318
+ apiOptions.isMobile &&
319
+ styles.nextHighlighted,
320
+ sat && styles.satRadioOption,
321
+ satShowCorrectness &&
322
+ !choice.correct &&
323
+ styles.satRadioOptionIncorrect,
324
+ satShowCorrectness &&
325
+ choice.correct &&
326
+ styles.satRadioOptionCorrect,
327
+ satShowCorrectnessNext &&
328
+ !nextChoice.correct &&
329
+ styles.satRadioOptionNextIncorrect,
330
+ satShowCorrectnessNext &&
331
+ nextChoice.correct &&
332
+ styles.satRadioOptionNextCorrect,
333
+ sat &&
334
+ reviewModeRubric &&
335
+ styles.satReviewRadioOption,
336
+ );
337
+ };
338
+
339
+ // HACK(abdulrahman): Preloads the selection-state
340
+ // css because of a bug that causes iOS to lag
341
+ // when selecting the button for the first time.
342
+ aphroditeClassName(true);
343
+
344
+ let correctnessClass;
345
+ // reviewMode is only true if there's a rubric
346
+ // but Flow doesn't understand that
347
+ if (reviewMode && reviewModeRubric) {
348
+ correctnessClass = reviewModeRubric.choices[i].correct
349
+ ? ApiClassNames.CORRECT
350
+ : ApiClassNames.INCORRECT;
351
+ }
352
+ const className: $ReadOnlyArray<string> = classNames(
353
+ aphroditeClassName(choice.checked),
354
+ // TODO(aria): Make test case for these API
355
+ // classNames
356
+ ApiClassNames.RADIO.OPTION,
357
+ choice.checked && ApiClassNames.RADIO.SELECTED,
358
+ correctnessClass,
359
+ );
360
+
361
+ // In edit mode, the Choice renders a Div in order to
362
+ // allow for the contentEditable area to be selected
363
+ // (label forces any clicks inside to select the input
364
+ // element) We have to add some extra behavior to make
365
+ // sure that we can still check the choice.
366
+ let listElem = null;
367
+ let clickHandler = null;
368
+ if (editMode) {
369
+ clickHandler = (e) => {
370
+ // Traverse the parent nodes of the clicked
371
+ // element.
372
+ let elem = e.target;
373
+ while (elem && elem !== listElem) {
374
+ // If the clicked element is inside of the
375
+ // radio icon, then we want to trigger the
376
+ // check by flipping the choice of the icon.
377
+ if (elem.getAttribute("data-is-radio-icon")) {
378
+ this.updateChoice(i, {
379
+ checked: !choice.checked,
380
+ crossedOut: choice.crossedOut,
381
+ });
382
+ return;
383
+ }
384
+ elem = elem.parentNode;
385
+ }
386
+ };
387
+ }
388
+
389
+ // TODO(mattdr): Index isn't a *good* choice of key
390
+ // here; is there a better one? Can we use choice
391
+ // content somehow? Would changing our choice of key
392
+ // somehow break something happening inside a choice's
393
+ // child Renderers, by changing when we mount/unmount?
394
+ return (
395
+ <li
396
+ key={i}
397
+ ref={(e) => (listElem = e)}
398
+ className={className}
399
+ onClick={clickHandler}
400
+ onTouchStart={
401
+ !labelWrap ? null : captureScratchpadTouchStart
402
+ }
403
+ >
404
+ <Element {...elementProps} />
405
+ </li>
406
+ );
407
+ }, this)}
408
+ </ul>
409
+ </fieldset>
410
+ );
411
+
412
+ // Allow for horizontal scrolling if content is too wide, which may be
413
+ // an issue especially on phones.
414
+ // This is disabled in SAT, since it conflicts with their theming.
415
+ return (
416
+ <div className={css(!sat && styles.responsiveContainer)}>
417
+ {fieldset}
418
+ </div>
419
+ );
420
+ }
400
421
 
401
- // We aren't going to change any choices' crossed-out state, but we
402
- // _are_ going to deselect all choices.
403
- const newCrossedOutList = this.props.choices.map((c) => c.crossedOut);
404
- const newCheckedList = this.props.choices.map((c) => false);
422
+ BaseRadio.defaultProps = {
423
+ editMode: false,
424
+ multipleSelect: false,
425
+ };
426
+
427
+ const styles: StyleDeclaration = StyleSheet.create({
428
+ instructions: {
429
+ display: "block",
430
+ color: styleConstants.gray17,
431
+ fontSize: 14,
432
+ lineHeight: 1.25,
433
+ fontStyle: "normal",
434
+ fontWeight: "bold",
435
+ marginBottom: 16,
436
+ },
437
+
438
+ instructionsMobile: {
439
+ fontSize: 18,
440
+ [mediaQueries.smOrSmaller]: {
441
+ fontSize: 16,
442
+ },
443
+ // TODO(emily): We want this to match choice text, which turns
444
+ // to 20px at min-width 1200px, but this media query is
445
+ // min-width 1280px because our media queries don't exactly
446
+ // match pure. Make those match up.
447
+ [mediaQueries.xl]: {
448
+ fontSize: 20,
449
+ },
450
+ },
451
+
452
+ radio: {
453
+ // Avoid centering
454
+ width: "100%",
455
+ },
456
+
457
+ responsiveRadioContainer: {
458
+ borderBottom: `1px solid ${styleConstants.radioBorderColor}`,
459
+ borderTop: `1px solid ${styleConstants.radioBorderColor}`,
460
+ width: "auto",
461
+ [mediaQueries.smOrSmaller]: {
462
+ marginLeft: styleConstants.negativePhoneMargin,
463
+ marginRight: styleConstants.negativePhoneMargin,
464
+ },
465
+ },
466
+
467
+ radioContainerFirstHighlighted: {
468
+ borderTop: `1px solid rgba(0, 0, 0, 0)`,
469
+ },
470
+
471
+ radioContainerLastHighlighted: {
472
+ borderBottom: `1px solid rgba(0, 0, 0, 0)`,
473
+ },
474
+
475
+ satRadio: {
476
+ background: "none",
477
+ marginLeft: 0,
478
+ userSelect: "none",
479
+ },
480
+
481
+ satRadioOption: {
482
+ margin: 0,
483
+ padding: 0,
484
+ borderBottom: `1px solid #ccc`,
485
+ ":first-child": {
486
+ borderTop: `1px solid #ccc`,
487
+ },
488
+ },
405
489
 
406
- // And, if the new choice isn't crossed out, we'll select it.
407
- if (!this.props.choices[newChoiceIndex].crossedOut) {
408
- newCheckedList[newChoiceIndex] = true;
409
- }
490
+ satRadioOptionCorrect: {
491
+ borderBottomColor: styleConstants.satCorrectBorderColor,
492
+ ":first-child": {
493
+ borderTopColor: styleConstants.satCorrectBorderColor,
494
+ },
495
+ },
410
496
 
411
- this.props.onChange({
412
- checked: newCheckedList,
413
- crossedOut: newCrossedOutList,
414
- });
415
- };
416
-
417
- render(): React.Node {
418
- const inputType = this.props.multipleSelect ? "checkbox" : "radio";
419
- const rubric = this.props.reviewModeRubric;
420
- const reviewMode = !!rubric;
421
-
422
- const styles = BaseRadio.styles;
423
- const sat = this.props.apiOptions.satStyling;
424
-
425
- const isMobile = this.props.apiOptions.isMobile;
426
-
427
- const choices = this.props.choices;
428
- const firstChoiceHighlighted = choices[0].highlighted;
429
- const lastChoiceHighlighted = choices[choices.length - 1].highlighted;
430
-
431
- const className = classNames(
432
- "perseus-widget-radio",
433
- !this.props.editMode && "perseus-rendered-radio",
434
- css(
435
- styles.radio,
436
- // SAT doesn't use the "responsive styling" as it conflicts
437
- // with their custom theming.
438
- !sat && styles.responsiveRadioContainer,
439
- !sat &&
440
- firstChoiceHighlighted &&
441
- isMobile &&
442
- styles.radioContainerFirstHighlighted,
443
- !sat &&
444
- lastChoiceHighlighted &&
445
- isMobile &&
446
- styles.radioContainerLastHighlighted,
447
- sat && styles.satRadio,
448
- ),
449
- );
450
-
451
- const instructionsClassName = classNames(
452
- "instructions",
453
- css(styles.instructions, isMobile && styles.instructionsMobile),
454
- );
455
- const instructions = this.getInstructionsText();
456
- const shouldShowInstructions = !sat;
457
-
458
- const responsiveClassName = css(styles.responsiveFieldset);
459
- const fieldset = (
460
- <fieldset
461
- className={`perseus-widget-radio-fieldset ${responsiveClassName}`}
462
- >
463
- <legend className="perseus-sr-only">{instructions}</legend>
464
- {shouldShowInstructions && (
465
- <div className={instructionsClassName}>{instructions}</div>
466
- )}
467
- <ul className={className} style={{listStyle: "none"}}>
468
- {this.props.choices.map(function (choice, i) {
469
- let Element = Choice;
470
- const elementProps = {
471
- ref: `radio${i}`,
472
- apiOptions: this.props.apiOptions,
473
- checked: choice.checked,
474
- crossedOut: choice.crossedOut,
475
- previouslyAnswered: choice.previouslyAnswered,
476
- reviewMode,
477
- correct: choice.correct,
478
- rationale: choice.rationale,
479
- content: choice.content,
480
- disabled:
481
- this.props.apiOptions.readOnly ||
482
- choice.disabled,
483
- editMode: this.props.editMode,
484
- groupName: this.state.radioGroupName,
485
- isLastChoice: i === this.props.choices.length - 1,
486
- showCorrectness:
487
- reviewMode || !!choice.showCorrectness,
488
- showRationale:
489
- choice.hasRationale &&
490
- (reviewMode || choice.showRationale),
491
- type: inputType,
492
- pos: i,
493
- deselectEnabled: this.deselectEnabled(),
494
- onChange: (newValues) => {
495
- this.updateChoice(i, newValues);
496
- },
497
- goToPrevChoice: () => this.goToChoice(i - 1),
498
- goToNextChoice: () => this.goToChoice(i + 1),
499
- };
497
+ satRadioOptionIncorrect: {
498
+ borderBottomColor: styleConstants.satIncorrectBorderColor,
499
+ ":first-child": {
500
+ borderTopColor: styleConstants.satIncorrectBorderColor,
501
+ },
502
+ },
500
503
 
501
- if (choice.isNoneOfTheAbove) {
502
- Element = ChoiceNoneAbove;
503
- _.extend(elementProps, {
504
- showContent: choice.revealNoneOfTheAbove,
505
- });
506
- }
507
-
508
- const nextChoice = this.props.choices[i + 1];
509
- const nextChoiceHighlighted =
510
- !!nextChoice && nextChoice.highlighted;
511
-
512
- const aphroditeClassName = (checked) => {
513
- // Whether or not to show correctness borders
514
- // for this choice and the next choice.
515
- const satShowCorrectness =
516
- sat && reviewMode && checked;
517
- const satShowCorrectnessNext =
518
- sat &&
519
- reviewMode &&
520
- nextChoice &&
521
- nextChoice.checked;
522
-
523
- return css(
524
- sharedStyles.aboveScratchpad,
525
- styles.item,
526
- !sat && styles.responsiveItem,
527
- !sat && checked && styles.selectedItem,
528
- !sat &&
529
- checked &&
530
- choice.highlighted &&
531
- styles.aboveBackdrop,
532
- !sat &&
533
- checked &&
534
- choice.highlighted &&
535
- this.props.apiOptions.isMobile &&
536
- styles.aboveBackdropMobile,
537
- !sat &&
538
- nextChoiceHighlighted &&
539
- this.props.apiOptions.isMobile &&
540
- styles.nextHighlighted,
541
- sat && styles.satRadioOption,
542
- satShowCorrectness &&
543
- !choice.correct &&
544
- styles.satRadioOptionIncorrect,
545
- satShowCorrectness &&
546
- choice.correct &&
547
- styles.satRadioOptionCorrect,
548
- satShowCorrectnessNext &&
549
- !nextChoice.correct &&
550
- styles.satRadioOptionNextIncorrect,
551
- satShowCorrectnessNext &&
552
- nextChoice.correct &&
553
- styles.satRadioOptionNextCorrect,
554
- sat && rubric && styles.satReviewRadioOption,
555
- );
556
- };
504
+ satRadioOptionNextCorrect: {
505
+ borderBottomColor: styleConstants.satCorrectBorderColor,
506
+ },
557
507
 
558
- // HACK(abdulrahman): Preloads the selection-state
559
- // css because of a bug that causes iOS to lag
560
- // when selecting the button for the first time.
561
- aphroditeClassName(true);
562
-
563
- const className = classNames(
564
- aphroditeClassName(choice.checked),
565
- // TODO(aria): Make test case for these API
566
- // classNames
567
- ApiClassNames.RADIO.OPTION,
568
- choice.checked && ApiClassNames.RADIO.SELECTED,
569
- reviewMode &&
570
- rubric.choices[i].correct &&
571
- ApiClassNames.CORRECT,
572
- reviewMode &&
573
- !rubric.choices[i].correct &&
574
- ApiClassNames.INCORRECT,
575
- );
508
+ satRadioOptionNextIncorrect: {
509
+ borderBottomColor: styleConstants.satIncorrectBorderColor,
510
+ },
576
511
 
577
- // In edit mode, the Choice renders a Div in order to
578
- // allow for the contentEditable area to be selected
579
- // (label forces any clicks inside to select the input
580
- // element) We have to add some extra behavior to make
581
- // sure that we can still check the choice.
582
- let listElem = null;
583
- let clickHandler = null;
584
- if (this.props.editMode) {
585
- clickHandler = (e) => {
586
- // Traverse the parent nodes of the clicked
587
- // element.
588
- let elem = e.target;
589
- while (elem && elem !== listElem) {
590
- // If the clicked element is inside of the
591
- // radio icon, then we want to trigger the
592
- // check by flipping the choice of the icon.
593
- if (
594
- elem.getAttribute("data-is-radio-icon")
595
- ) {
596
- this.updateChoice(i, {
597
- checked: !choice.checked,
598
- });
599
- return;
600
- }
601
- elem = elem.parentNode;
602
- }
603
- };
604
- }
605
-
606
- // TODO(mattdr): Index isn't a *good* choice of key
607
- // here; is there a better one? Can we use choice
608
- // content somehow? Would changing our choice of key
609
- // somehow break something happening inside a choice's
610
- // child Renderers, by changing when we mount/unmount?
611
- return (
612
- <li
613
- key={i}
614
- ref={(e) => (listElem = e)}
615
- className={className}
616
- onClick={clickHandler}
617
- onTouchStart={
618
- !this.props.labelWrap
619
- ? null
620
- : captureScratchpadTouchStart
621
- }
622
- >
623
- <Element {...elementProps} />
624
- </li>
625
- );
626
- }, this)}
627
- </ul>
628
- </fieldset>
629
- );
630
-
631
- // Allow for horizontal scrolling if content is too wide, which may be
632
- // an issue especially on phones.
633
- // This is disabled in SAT, since it conflicts with their theming.
634
- return (
635
- <div className={css(!sat && styles.responsiveContainer)}>
636
- {fieldset}
637
- </div>
638
- );
639
- }
640
- }
512
+ satReviewRadioOption: {
513
+ pointerEvents: "none",
514
+ },
515
+
516
+ item: {
517
+ marginLeft: 20,
518
+ },
519
+
520
+ responsiveItem: {
521
+ marginLeft: 0,
522
+ padding: 0,
523
+
524
+ ":not(:last-child)": {
525
+ borderBottom: `1px solid ${styleConstants.radioBorderColor}`,
526
+ },
527
+ },
528
+ selectedItem: {
529
+ background: "white",
530
+ },
531
+
532
+ aboveBackdrop: {
533
+ position: "relative",
534
+ // HACK(emily): We want selected choices to show up above our
535
+ // exercise backdrop, but below the exercise footer and
536
+ // "feedback popover" that shows up. This z-index is carefully
537
+ // coordinated between here and webapp. :(
538
+ zIndex: 1062,
539
+ },
540
+
541
+ aboveBackdropMobile: {
542
+ boxShadow:
543
+ "0 0 4px 0 rgba(0, 0, 0, 0.2)," + "0 0 2px 0 rgba(0, 0, 0, 0.1)",
544
+
545
+ ":not(:last-child)": {
546
+ borderBottom: `1px solid rgba(0, 0, 0, 0)`,
547
+ },
548
+ },
549
+
550
+ nextHighlighted: {
551
+ ":not(:last-child)": {
552
+ borderBottom: `1px solid rgba(0, 0, 0, 0)`,
553
+ },
554
+ },
555
+
556
+ responsiveContainer: {
557
+ overflow: "auto",
558
+ marginLeft: styleConstants.negativePhoneMargin,
559
+ marginRight: styleConstants.negativePhoneMargin,
560
+ paddingLeft: styleConstants.phoneMargin,
561
+ // paddingRight is handled by responsiveFieldset
562
+ },
563
+
564
+ responsiveFieldset: {
565
+ paddingRight: styleConstants.phoneMargin,
566
+ },
567
+ });
641
568
 
642
569
  export default BaseRadio;