@khanacademy/perseus 0.4.3 → 1.1.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 (71) hide show
  1. package/.eslintrc.js +6 -1
  2. package/CHANGELOG.md +45 -0
  3. package/dist/es/index.css +15 -8
  4. package/dist/es/index.js +1214 -1404
  5. package/dist/es/index.js.map +1 -1
  6. package/dist/index.css +15 -8
  7. package/dist/index.js +1217 -1411
  8. package/dist/index.js.map +1 -1
  9. package/dist/strings.js +0 -4
  10. package/package.json +9 -8
  11. package/src/components/number-input.jsx +2 -2
  12. package/src/components/simple-keypad-input.jsx +18 -21
  13. package/src/index.js +1 -1
  14. package/src/multi-items/__tests__/__snapshots__/multi-renderer_test.js.snap +426 -135
  15. package/src/multi-items/__tests__/multi-renderer_test.js +9 -9
  16. package/src/perseus-api.jsx +10 -36
  17. package/src/perseus-types.js +18 -145
  18. package/src/styles/constants.js +0 -2
  19. package/src/styles/perseus-renderer.less +8 -2
  20. package/src/styles/variables.less +0 -2
  21. package/src/styles/widgets/passage.less +9 -5
  22. package/src/types.js +18 -35
  23. package/src/util/answer-types.js +2 -3
  24. package/src/util/math.js +3 -10
  25. package/src/util.js +0 -6
  26. package/src/version.js +1 -1
  27. package/src/widgets/__stories__/numeric-input.stories.jsx +43 -2
  28. package/src/widgets/__testdata__/base-radio_testdata.js +22 -0
  29. package/src/widgets/__tests__/__snapshots__/definition_test.js.snap +4 -4
  30. package/src/widgets/__tests__/__snapshots__/group_test.jsx.snap +291 -130
  31. package/src/widgets/__tests__/__snapshots__/radio_test.jsx.snap +3293 -1415
  32. package/src/widgets/__tests__/group_test.jsx +4 -4
  33. package/src/widgets/__tests__/numeric-input_test.jsx +12 -8
  34. package/src/widgets/__tests__/passage_test.jsx +81 -0
  35. package/src/widgets/__tests__/radio/base-radio_test.jsx +291 -41
  36. package/src/widgets/__tests__/radio/choice-icon_test.jsx +67 -0
  37. package/src/widgets/__tests__/radio/choice_test.jsx +302 -0
  38. package/src/widgets/__tests__/radio/focus-ring_test.jsx +23 -0
  39. package/src/widgets/__tests__/radio/option-status_test.jsx +65 -0
  40. package/src/widgets/__tests__/radio_test.jsx +39 -15
  41. package/src/widgets/definition.jsx +1 -1
  42. package/src/widgets/group.jsx +84 -102
  43. package/src/widgets/input-number.jsx +6 -1
  44. package/src/widgets/numeric-input.jsx +87 -80
  45. package/src/widgets/passage/__tests__/passage-markdown_test.jsx +0 -9
  46. package/src/widgets/passage/get-line-height-for-node.js +4 -5
  47. package/src/widgets/passage/passage-markdown.jsx +176 -120
  48. package/src/widgets/passage.jsx +48 -46
  49. package/src/widgets/radio/__stories__/base-radio.stories.jsx +164 -17
  50. package/src/widgets/radio/__stories__/choice-icon.stories.jsx +202 -111
  51. package/src/widgets/radio/__stories__/choice-none-above.stories.jsx +21 -6
  52. package/src/widgets/radio/__stories__/choice.stories.jsx +138 -0
  53. package/src/widgets/radio/__stories__/focus-ring.stories.jsx +29 -23
  54. package/src/widgets/radio/__stories__/option-status.stories.jsx +93 -99
  55. package/src/widgets/radio/base-radio.jsx +485 -556
  56. package/src/widgets/radio/choice-a11y-text.js +62 -0
  57. package/src/widgets/radio/choice-icon/choice-icon.jsx +87 -0
  58. package/src/widgets/radio/choice-icon/cross-out-line.jsx +55 -0
  59. package/src/widgets/radio/choice-icon/library-choice-icon.jsx +214 -0
  60. package/src/widgets/radio/choice-icon/sat-choice-icon.jsx +120 -0
  61. package/src/widgets/radio/choice-icon/shared-styles.js +17 -0
  62. package/src/widgets/radio/choice-none-above.jsx +49 -40
  63. package/src/widgets/radio/choice.jsx +319 -347
  64. package/src/widgets/radio/choice.jsx.fixture.js +0 -18
  65. package/src/widgets/radio/focus-ring.jsx +29 -22
  66. package/src/widgets/radio/option-status.jsx +58 -36
  67. package/src/widgets/radio/widget.jsx +181 -136
  68. package/src/widgets/radio.jsx +16 -17
  69. package/src/widgets/radio/__stories__/cross-out-button.stories.jsx +0 -64
  70. package/src/widgets/radio/choice-icon.jsx +0 -469
  71. package/src/widgets/radio/cross-out-button.jsx +0 -134
@@ -9,187 +9,106 @@ import {Popover, PopoverContent} from "@khanacademy/wonder-blocks-popover";
9
9
  import Spacing from "@khanacademy/wonder-blocks-spacing";
10
10
  import {StyleSheet, css} from "aphrodite";
11
11
  import classNames from "classnames";
12
- import PropTypes from "prop-types";
13
12
  import * as React from "react";
13
+ import {useState, useEffect} from "react";
14
14
  import _ from "underscore";
15
15
 
16
16
  import Icon from "../../components/icon.jsx";
17
+ import {ApiOptions} from "../../perseus-api.jsx";
17
18
  import * as styleConstants from "../../styles/constants.js";
18
19
  import mediaQueries from "../../styles/media-queries.js";
19
20
 
20
- import ChoiceIcon from "./choice-icon.jsx";
21
+ import getA11yText from "./choice-a11y-text.js";
22
+ import ChoiceIcon from "./choice-icon/choice-icon.jsx";
21
23
  import OptionStatus from "./option-status.jsx";
22
24
  import {getChoiceLetter} from "./util.js";
23
25
 
26
+ import type {APIOptions} from "../../types.js";
27
+
24
28
  const intermediateCheckboxPadding = `16px 16px`;
25
29
  const intermediateCheckboxPaddingPhone = `12px 16px`;
26
30
 
27
- export const TouchIgnoreTimeout = 10;
28
-
29
31
  const ellipsisHorizontalIcon = {
30
32
  path: "M27.218 6.82l0 13.578q0 2.852-1.984 4.836t-4.836 1.984l-13.578 0q-2.852 0-4.836-1.984t-1.984-4.836l0-13.578q0-2.852 1.984-4.836t4.836-1.984l13.578 0q2.852 0 4.836 1.984t1.984 4.836zm36.27 0l0 13.578q0 2.852-1.984 4.836t-4.836 1.984l-13.578 0q-2.852 0-4.836-1.984t-1.984-4.836l0-13.578q0-2.852 1.984-4.836t4.836-1.984l13.578 0q2.852 0 4.836 1.984t1.984 4.836zm36.27 0l0 13.578q0 2.852-1.984 4.836t-4.836 1.984l-13.578 0q-2.852 0-4.836-1.984t-1.984-4.836l0-13.578q0-2.852 1.984-4.836t4.836-1.984l13.578 0q2.852 0 4.836 1.984t1.984 4.836z",
31
33
  width: 100,
32
34
  height: 27.284,
33
35
  };
34
36
 
35
- type State = {|
36
- isInputFocused: boolean,
37
- isInputActive: boolean,
37
+ export type ChoiceProps = {|
38
+ apiOptions: APIOptions,
39
+ checked: boolean,
40
+ rationale: React.Node,
41
+ content: React.Node,
42
+ correct: boolean,
43
+ disabled: boolean,
44
+ // This indicates the position of the choice relative to others
45
+ // (so that we can display a nice little (A), (B), etc. next to it)
46
+ // Also used to generate an id for each input.
47
+ pos: number,
48
+ reviewMode: boolean,
49
+ showRationale: boolean,
50
+ showCorrectness: boolean,
51
+ multipleSelect: boolean,
52
+
53
+ // Indicates whether the user has "crossed out" this choice, meaning
54
+ // that they don't think it's correct. This value does not affect
55
+ // scoring or other behavior; it's just a note for the user's
56
+ // reference.
57
+ crossedOut: boolean,
58
+
59
+ // Indicates that the user has previously selected this answer. These
60
+ // answers may be rendered orange in review, rather than grey if
61
+ // incorrect.
62
+ previouslyAnswered: boolean,
63
+
64
+ // A callback indicating that this choice has changed. Its argument is
65
+ // an object with two keys: `checked` and `crossedOut`. Each contains a
66
+ // boolean value specifying the new checked and crossed-out value of
67
+ // this choice.
68
+ onChange: (newValues: {checked: boolean, crossedOut: boolean}) => void,
38
69
  |};
39
70
 
40
- class Choice extends React.Component<$FlowFixMe, State> {
41
- justFinishedTouch: boolean;
42
- justFinishedTimeoutID: TimeoutID;
43
- _input: ?HTMLInputElement;
44
-
45
- static propTypes = {
46
- // TODO(kevinb) use Options.propTypes from perseus-api.jsx
47
- // This change will also require make sure that item-renderer.jsx and
48
- // server-item-renderer.jsx have appropriate defaults for apiOptions
49
- // because many of the properties on Options.propTypes are required.
50
- apiOptions: PropTypes.shape({
51
- satStyling: PropTypes.bool,
52
- isMobile: PropTypes.bool,
53
- styling: PropTypes.shape({
54
- radioStyleVersion: PropTypes.oneOf(["intermediate", "final"]),
55
- primaryProductColor: PropTypes.string,
56
- }),
57
- readOnly: PropTypes.bool,
58
- }),
59
- checked: PropTypes.bool,
60
- className: PropTypes.string,
61
- rationale: PropTypes.node,
62
- content: PropTypes.node,
63
- correct: PropTypes.bool,
64
- deselectEnabled: PropTypes.bool,
65
- disabled: PropTypes.bool,
66
- editMode: PropTypes.bool,
67
- groupName: PropTypes.string,
68
- isLastChoice: PropTypes.bool, // Needed for border styling
69
- // This indicates the position of the choice relative to others
70
- // (so that we can display a nice little (A), (B), etc. next to it)
71
- // Also used to generate an id for each input.
72
- pos: PropTypes.number,
73
- reviewMode: PropTypes.bool,
74
- showRationale: PropTypes.bool,
75
- showCorrectness: PropTypes.bool,
76
- type: PropTypes.string,
77
-
78
- // Indicates whether the user has "crossed out" this choice, meaning
79
- // that they don't think it's correct. This value does not affect
80
- // scoring or other behavior; it's just a note for the user's
81
- // reference.
82
- crossedOut: PropTypes.bool,
83
-
84
- // Indicates that the user has previously selected this answer. These
85
- // answers may be rendered orange in review, rather than grey if
86
- // incorrect.
87
- previouslyAnswered: PropTypes.bool,
88
-
89
- // A callback indicating that this choice has changed. Its argument is
90
- // an object with two keys: `checked` and `crossedOut`. Each contains a
91
- // boolean value specifying the new checked and crossed-out value of
92
- // this choice.
93
- onChange: PropTypes.func,
94
-
95
- // Callbacks for when the user presses the up arrow or down arrow
96
- // (respectively) to navigate between choices. When called, the parent
97
- // finds the prev/next choice, focuses the input, and checks the radio
98
- // button if appropriate. (The reason we have custom behavior is that
99
- // we actually _don't_ want to auto-select crossed-out choices!)
100
- goToPrevChoice: PropTypes.func.isRequired,
101
- goToNextChoice: PropTypes.func.isRequired,
102
- };
103
-
104
- static defaultProps: $FlowFixMe = {
105
- apiOptions: {
106
- styling: {},
107
- },
108
- checked: false,
109
- classSet: {},
110
- correct: false,
111
- disabled: false,
112
- editMode: false,
113
- onChange: function () {},
114
- showRationale: false,
115
- type: "radio",
116
- pos: 0,
117
- previouslyAnswered: false,
118
- };
119
-
120
- state: $FlowFixMe = {
121
- isInputFocused: false,
122
- isInputActive: false,
123
- };
124
-
125
- UNSAFE_componentWillUpdate(nextProps: $FlowFixMe) {
126
- if (this.state.isInputFocused && nextProps.disabled) {
127
- this.setState({
128
- isInputFocused: false,
129
- });
130
- }
131
- }
132
-
133
- inputRef: ($FlowFixMe) => void = (ref) => {
134
- this._input = ref;
135
- };
136
-
137
- renderOptionStatus: () => React.Node = () => {
138
- const {correct, checked, crossedOut, reviewMode, previouslyAnswered} =
139
- this.props;
71
+ type WithForwardRef = {|forwardedRef: React.Ref<"button">|};
140
72
 
141
- // Option status is exclued for SAT
142
- if (this.props.apiOptions.satStyling) {
143
- return null;
144
- }
73
+ type ChoicePropsWithForwardRef = {|
74
+ ...ChoiceProps,
75
+ ...WithForwardRef,
76
+ |};
145
77
 
146
- // Option status is shown only in review mode, or for incorrectly
147
- // answered items.
148
- if (!reviewMode && !previouslyAnswered) {
149
- return null;
150
- }
78
+ // Note(TB): Received errors when using useUniqueIdWithMock
79
+ // so created this workaround function. Will update when
80
+ // useUniqueIdWithMock is available.
81
+ // https://khanacademy.atlassian.net/browse/FEI-4861?atlOrigin=eyJpIjoiNDJlZWMwNjM1NWJhNDBkMWFjY2FmN2I0ZjcxZmQxOGUiLCJwIjoiaiJ9
82
+ let id = 0;
83
+ function uniqueId() {
84
+ return `choice-${id++}`;
85
+ }
151
86
 
152
- return (
153
- <OptionStatus
154
- checked={checked}
155
- correct={correct}
156
- crossedOut={crossedOut}
157
- previouslyAnswered={previouslyAnswered}
158
- />
159
- );
160
- };
161
-
162
- renderChoiceIcon: (args: {|
163
- isFocused: boolean,
164
- isPressed: boolean,
165
- |}) => React.Node = ({isFocused, isPressed}) => {
166
- const {radioStyleVersion, primaryProductColor} =
167
- this.props.apiOptions.styling;
168
- const finalStyles =
169
- typeof radioStyleVersion === "undefined"
170
- ? false
171
- : radioStyleVersion === "final";
172
-
173
- if (!finalStyles && !this.props.apiOptions.satStyling) {
174
- return null;
87
+ function Choice(props: ChoicePropsWithForwardRef): React.Node {
88
+ const {
89
+ disabled,
90
+ checked,
91
+ content,
92
+ crossedOut,
93
+ showCorrectness,
94
+ multipleSelect,
95
+ onChange,
96
+ reviewMode,
97
+ correct,
98
+ apiOptions,
99
+ previouslyAnswered,
100
+ pos,
101
+ showRationale,
102
+ rationale,
103
+ forwardedRef,
104
+ } = props;
105
+ const [isInputFocused, setIsInputFocused] = useState(false);
106
+
107
+ useEffect(() => {
108
+ if (isInputFocused && disabled) {
109
+ setIsInputFocused(false);
175
110
  }
176
-
177
- return (
178
- <ChoiceIcon
179
- pos={this.props.pos}
180
- correct={this.props.correct}
181
- crossedOut={this.props.crossedOut}
182
- pressed={isPressed}
183
- focused={isFocused}
184
- checked={this.props.checked}
185
- showCorrectness={this.props.showCorrectness}
186
- reviewMode={this.props.reviewMode}
187
- product={this.props.apiOptions.satStyling ? "sat" : "library"}
188
- primaryProductColor={primaryProductColor}
189
- previouslyAnswered={this.props.previouslyAnswered}
190
- />
191
- );
192
- };
111
+ }, [disabled, isInputFocused, setIsInputFocused]);
193
112
 
194
113
  // Call `this.props.onChange` with the given values. Any keys that are not
195
114
  // specified will be filled in with the current value. (For example, if
@@ -198,217 +117,266 @@ class Choice extends React.Component<$FlowFixMe, State> {
198
117
  //
199
118
  // This enables us to use shorthand inside this component, while
200
119
  // maintaining a consistent API for the parent.
201
- _sendChange: ({|checked?: boolean, crossedOut?: boolean|}) => void = (
202
- newValues,
203
- ) => {
204
- const checked = newValues.checked ?? this.props.checked;
205
- const crossedOut = newValues.crossedOut ?? this.props.crossedOut;
206
- this.props.onChange({checked, crossedOut});
207
- };
208
-
209
- /**
210
- * Public method. Focus the choice's <input> element.
211
- */
212
- focusInput: () => void = () => {
213
- this._input?.focus();
214
- };
215
-
216
- render(): React.Node {
217
- const sat = this.props.apiOptions.satStyling;
218
-
219
- const {reviewMode, correct, checked} = this.props;
220
- // HACK: while most of the styling for rendering SAT items is handled
221
- // via aphrodite, we also need to assign normal CSS classnames here to
222
- // special-case the coloring of MathJax formulas (see .MathJax .math in
223
- // stylesheets/task-package/tasks.less)
224
- const satCorrectChoice = sat && reviewMode && correct;
225
- const satIncorrectChecked = sat && reviewMode && !correct && checked;
226
- const descriptionClassName = classNames(
227
- "description",
228
- satCorrectChoice && "sat-correct",
229
- satIncorrectChecked && "sat-incorrect",
230
- css(
231
- !sat && styles.description,
232
- sat && styles.satDescription,
233
- satCorrectChoice && styles.satDescriptionCorrect,
234
- satCorrectChoice &&
235
- checked &&
236
- styles.satDescriptionCorrectChecked,
237
- satIncorrectChecked && styles.satDescriptionIncorrectChecked,
238
- ),
239
- );
240
-
241
- const rationaleClassName = classNames(
242
- "perseus-radio-rationale-content",
243
- css(
244
- styles.rationale,
245
- !sat && styles.nonSatRationale,
246
- sat && styles.satReviewRationale,
247
- ),
248
- );
249
-
250
- // We want to show the choices as dimmed out when the choices are
251
- // disabled. However, we don't want to do this in the SAT product and
252
- // we also don't want to do this when we're in review mode in the
253
- // content library.
254
- const showDimmed =
255
- (!sat && !reviewMode && this.props.apiOptions.readOnly) ||
256
- this.props.crossedOut;
257
-
258
- return (
120
+ function sendChange(newValues: {|
121
+ checked?: boolean,
122
+ crossedOut?: boolean,
123
+ |}) {
124
+ const updatedChecked = newValues.checked ?? checked;
125
+ const updatedCrossedOut = newValues.crossedOut ?? crossedOut;
126
+ onChange({checked: updatedChecked, crossedOut: updatedCrossedOut});
127
+ }
128
+
129
+ // HACK: while most of the styling for rendering SAT items is handled
130
+ // via aphrodite, we also need to assign normal CSS classnames here to
131
+ // special-case the coloring of MathJax formulas (see .MathJax .math in
132
+ // stylesheets/task-package/tasks.less)
133
+ const satCorrectChoice = apiOptions.satStyling && reviewMode && correct;
134
+ const satIncorrectChecked =
135
+ apiOptions.satStyling && reviewMode && !correct && checked;
136
+ const descriptionClassName = classNames(
137
+ "description",
138
+ satCorrectChoice && "sat-correct",
139
+ satIncorrectChecked && "sat-incorrect",
140
+ css(
141
+ !apiOptions.satStyling && styles.description,
142
+ apiOptions.satStyling && styles.satDescription,
143
+ satCorrectChoice && styles.satDescriptionCorrect,
144
+ satCorrectChoice && checked && styles.satDescriptionCorrectChecked,
145
+ satIncorrectChecked && styles.satDescriptionIncorrectChecked,
146
+ ),
147
+ );
148
+
149
+ const rationaleClassName = classNames(
150
+ "perseus-radio-rationale-content",
151
+ css(
152
+ styles.rationale,
153
+ !apiOptions.satStyling && styles.nonSatRationale,
154
+ apiOptions.satStyling && styles.satReviewRationale,
155
+ ),
156
+ );
157
+
158
+ // We want to show the choices as dimmed out when the choices are
159
+ // disabled. However, we don't want to do this in the SAT product and
160
+ // we also don't want to do this when we're in review mode in the
161
+ // content library.
162
+ const showDimmed =
163
+ (!apiOptions.satStyling && !reviewMode && apiOptions.readOnly) ||
164
+ crossedOut;
165
+
166
+ const letter = getChoiceLetter(pos);
167
+ const a11yText = getA11yText(
168
+ letter,
169
+ checked,
170
+ correct,
171
+ crossedOut,
172
+ showCorrectness,
173
+ );
174
+ const choiceId = uniqueId();
175
+
176
+ return (
177
+ <div
178
+ style={{
179
+ dispay: "flex",
180
+ flexDirection: "column",
181
+ }}
182
+ >
259
183
  <div
260
184
  style={{
261
- dispay: "flex",
262
- flexDirection: "column",
185
+ display: "flex",
186
+ flexDirection: "row",
187
+ width: "100%",
188
+ opacity: showDimmed ? 0.5 : 1.0,
263
189
  }}
264
190
  >
265
- <div
266
- style={{
267
- display: "flex",
268
- flexDirection: "row",
269
- width: "100%",
270
- opacity: showDimmed ? 0.5 : 1.0,
271
- }}
272
- >
273
- <Clickable
191
+ <div className="perseus-sr-only">
192
+ <input
193
+ type={multipleSelect ? "checkbox" : "radio"}
194
+ id={choiceId}
195
+ checked={checked}
274
196
  onClick={() => {
275
197
  // If we're checking a crossed-out option, let's
276
198
  // also uncross it.
277
- this._sendChange({
278
- checked: true,
199
+ sendChange({
200
+ checked: !checked,
279
201
  crossedOut: false,
280
202
  });
281
203
  }}
282
- className={descriptionClassName}
204
+ onChange={() => {}}
283
205
  disabled={
284
- this.props.disabled ||
285
- this.props.apiOptions.staticRender ||
286
- this.props.apiOptions.readOnly
206
+ disabled ||
207
+ apiOptions.staticRender ||
208
+ apiOptions.readOnly
287
209
  }
288
- aria-label={`Select Choice ${getChoiceLetter(
289
- this.props.pos,
290
- )}`}
291
- aria-checked={this.props.checked}
292
- role={"checkbox"}
293
- style={{flex: 1}}
294
- >
295
- {({hovered, focused, pressed}) => (
296
- <div
210
+ tabIndex={-1}
211
+ />
212
+ <label htmlFor={choiceId}>
213
+ {a11yText} &nbsp; {content}
214
+ </label>
215
+ </div>
216
+ <Clickable
217
+ onClick={() => {
218
+ // If we're checking a crossed-out option, let's
219
+ // also uncross it.
220
+ sendChange({
221
+ checked: !checked,
222
+ crossedOut: false,
223
+ });
224
+ }}
225
+ className={descriptionClassName}
226
+ disabled={
227
+ disabled ||
228
+ apiOptions.staticRender ||
229
+ apiOptions.readOnly
230
+ }
231
+ style={{flex: 1}}
232
+ ref={(forwardedRef: any)}
233
+ aria-hidden="true"
234
+ >
235
+ {({hovered, focused, pressed}) => (
236
+ <div
237
+ style={{
238
+ display: "flex",
239
+ flexDirection: "row",
240
+ justifyContent: "center",
241
+ alignContent: "center",
242
+ paddingTop: Spacing.xSmall_8,
243
+ paddingBottom: Spacing.xSmall_8,
244
+ }}
245
+ >
246
+ <ChoiceIcon
247
+ pos={pos}
248
+ correct={correct}
249
+ crossedOut={crossedOut}
250
+ pressed={pressed}
251
+ focused={focused}
252
+ checked={checked}
253
+ hovered={hovered}
254
+ showCorrectness={showCorrectness}
255
+ multipleSelect={multipleSelect}
256
+ reviewMode={reviewMode}
257
+ product={
258
+ apiOptions.satStyling ? "sat" : "library"
259
+ }
260
+ primaryProductColor={
261
+ apiOptions.styling?.primaryProductColor
262
+ }
263
+ previouslyAnswered={previouslyAnswered}
264
+ />
265
+ <span
297
266
  style={{
298
- display: "flex",
299
- flexDirection: "row",
300
- justifyContent: "center",
301
- alignContent: "center",
302
- paddingTop: Spacing.xSmall_8,
303
- paddingBottom: Spacing.xSmall_8,
267
+ paddingLeft: Spacing.small_12,
268
+ textAlign: "left",
269
+ flex: 1,
270
+ paddingTop: 4,
304
271
  }}
305
272
  >
306
- <span>
307
- {this.renderChoiceIcon({
308
- isFocused: focused,
309
- isPressed: pressed,
310
- })}
311
- </span>
312
- <span
313
- style={{
314
- paddingLeft: Spacing.small_12,
315
- textAlign: "left",
316
- flex: 1,
317
- paddingTop: 4,
318
- }}
319
- >
320
- <div>{this.renderOptionStatus()}</div>
321
- <div>{this.props.content}</div>
322
- </span>
323
- </div>
324
- )}
325
- </Clickable>
326
-
327
- {this.props.apiOptions.crossOutEnabled && !reviewMode && (
328
- <Popover
329
- dismissEnabled
330
- content={({close}) => (
331
- <PopoverContent
332
- title="Cross out"
333
- content="Cross out option"
334
- closeButtonVisible
335
- actions={
336
- <View>
337
- <Strut size={Spacing.medium_16} />
338
- <Button
339
- kind="primary"
340
- aria-label={`Cross out Choice ${getChoiceLetter(
341
- this.props.pos,
342
- )}`}
343
- disabled={
344
- this.props.apiOptions
345
- .readOnly || reviewMode
346
- }
347
- onClick={() => {
348
- if (
349
- !this.props.crossedOut
350
- ) {
351
- // If we're crossing
352
- // out a checked
353
- // option, let's also
354
- // uncheck it.
355
- this._sendChange({
356
- checked: false,
357
- crossedOut: true,
358
- });
359
- } else {
360
- this._sendChange({
361
- crossedOut: false,
362
- });
363
- }
364
- close();
365
- }}
366
- >
367
- {this.props.crossedOut
368
- ? "Bring back"
369
- : "Cross out"}
370
- </Button>
371
- </View>
372
- }
373
- />
374
- )}
375
- >
376
- {({open}) => (
377
- <Clickable
378
- onClick={open}
379
- aria-label={`Open menu for Choice ${getChoiceLetter(
380
- this.props.pos,
381
- )}`}
382
- disabled={
383
- this.props.apiOptions.staticRender
384
- }
385
- >
386
- {({hovered, focused, pressed}) => (
387
- <Icon
388
- icon={ellipsisHorizontalIcon}
389
- size={3}
390
- color={Color.offBlack64}
391
- />
392
- )}
393
- </Clickable>
394
- )}
395
- </Popover>
273
+ <div>
274
+ <OptionStatus
275
+ checked={checked}
276
+ correct={correct}
277
+ crossedOut={crossedOut}
278
+ previouslyAnswered={previouslyAnswered}
279
+ reviewMode={reviewMode}
280
+ satStyling={apiOptions.satStyling}
281
+ />
282
+ </div>
283
+ <div>{content}</div>
284
+ </span>
285
+ </div>
396
286
  )}
397
- </div>
398
-
399
- {this.props.showRationale && (
400
- <div
401
- className={rationaleClassName}
402
- data-test-id={`perseus-radio-rationale-content-${this.props.pos}`}
287
+ </Clickable>
288
+
289
+ {apiOptions.crossOutEnabled && !reviewMode && (
290
+ <Popover
291
+ dismissEnabled
292
+ content={({close}) => (
293
+ <PopoverContent
294
+ title="Cross out"
295
+ content="Cross out option"
296
+ closeButtonVisible
297
+ actions={
298
+ <View>
299
+ <Strut size={Spacing.medium_16} />
300
+ <Button
301
+ kind="primary"
302
+ aria-label={`Cross out Choice ${getChoiceLetter(
303
+ pos,
304
+ )}`}
305
+ disabled={
306
+ apiOptions.readOnly ||
307
+ reviewMode
308
+ }
309
+ onClick={() => {
310
+ if (!crossedOut) {
311
+ // If we're crossing
312
+ // out a checked
313
+ // option, let's also
314
+ // uncheck it.
315
+ sendChange({
316
+ checked: false,
317
+ crossedOut: true,
318
+ });
319
+ } else {
320
+ sendChange({
321
+ crossedOut: false,
322
+ });
323
+ }
324
+ close();
325
+ }}
326
+ >
327
+ {crossedOut
328
+ ? "Bring back"
329
+ : "Cross out"}
330
+ </Button>
331
+ </View>
332
+ }
333
+ />
334
+ )}
403
335
  >
404
- {this.props.rationale}
405
- </div>
336
+ {({open}) => (
337
+ <Clickable
338
+ onClick={open}
339
+ aria-label={`Open menu for Choice ${getChoiceLetter(
340
+ pos,
341
+ )}`}
342
+ disabled={apiOptions.staticRender}
343
+ >
344
+ {({hovered, focused, pressed}) => (
345
+ <Icon
346
+ icon={ellipsisHorizontalIcon}
347
+ size={3}
348
+ color={Color.offBlack64}
349
+ />
350
+ )}
351
+ </Clickable>
352
+ )}
353
+ </Popover>
406
354
  )}
407
355
  </div>
408
- );
409
- }
356
+
357
+ {showRationale && (
358
+ <div
359
+ className={rationaleClassName}
360
+ data-test-id={`perseus-radio-rationale-content-${pos}`}
361
+ >
362
+ {rationale}
363
+ </div>
364
+ )}
365
+ </div>
366
+ );
410
367
  }
411
368
 
369
+ Choice.defaultProps = {
370
+ disabled: false,
371
+ checked: false,
372
+ onChange: (newValues: {checked: boolean, crossedOut: boolean}): void => {},
373
+ correct: false,
374
+ apiOptions: ApiOptions.defaults,
375
+ previouslyAnswered: false,
376
+ pos: 0,
377
+ showRationale: false,
378
+ };
379
+
412
380
  const styles = StyleSheet.create({
413
381
  description: {
414
382
  display: "inline-block",
@@ -457,4 +425,8 @@ const styles = StyleSheet.create({
457
425
  },
458
426
  });
459
427
 
460
- export default Choice;
428
+ type ExportProps = $Diff<React.ElementConfig<typeof Choice>, WithForwardRef>;
429
+
430
+ export default (React.forwardRef<ExportProps, HTMLButtonElement>(
431
+ (props, ref) => <Choice {...props} forwardedRef={ref} />,
432
+ ): React.AbstractComponent<ExportProps, HTMLButtonElement>);