@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
@@ -9,187 +9,96 @@ 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 ChoiceIcon from "./choice-icon/choice-icon.jsx";
21
22
  import OptionStatus from "./option-status.jsx";
22
23
  import {getChoiceLetter} from "./util.js";
23
24
 
25
+ import type {APIOptions} from "../../types.js";
26
+
24
27
  const intermediateCheckboxPadding = `16px 16px`;
25
28
  const intermediateCheckboxPaddingPhone = `12px 16px`;
26
29
 
27
- export const TouchIgnoreTimeout = 10;
28
-
29
30
  const ellipsisHorizontalIcon = {
30
31
  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
32
  width: 100,
32
33
  height: 27.284,
33
34
  };
34
35
 
35
- type State = {|
36
- isInputFocused: boolean,
37
- isInputActive: boolean,
36
+ export type ChoiceProps = {|
37
+ apiOptions: APIOptions,
38
+ checked: boolean,
39
+ rationale: React.Node,
40
+ content: React.Node,
41
+ correct: boolean,
42
+ disabled: boolean,
43
+ // This indicates the position of the choice relative to others
44
+ // (so that we can display a nice little (A), (B), etc. next to it)
45
+ // Also used to generate an id for each input.
46
+ pos: number,
47
+ reviewMode: boolean,
48
+ showRationale: boolean,
49
+ showCorrectness: boolean,
50
+ multipleSelect: boolean,
51
+
52
+ // Indicates whether the user has "crossed out" this choice, meaning
53
+ // that they don't think it's correct. This value does not affect
54
+ // scoring or other behavior; it's just a note for the user's
55
+ // reference.
56
+ crossedOut: boolean,
57
+
58
+ // Indicates that the user has previously selected this answer. These
59
+ // answers may be rendered orange in review, rather than grey if
60
+ // incorrect.
61
+ previouslyAnswered: boolean,
62
+
63
+ // A callback indicating that this choice has changed. Its argument is
64
+ // an object with two keys: `checked` and `crossedOut`. Each contains a
65
+ // boolean value specifying the new checked and crossed-out value of
66
+ // this choice.
67
+ onChange: (newValues: {checked: boolean, crossedOut: boolean}) => void,
38
68
  |};
39
69
 
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
- }
70
+ type WithForwardRef = {|forwardedRef: React.Ref<"button">|};
132
71
 
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;
140
-
141
- // Option status is exclued for SAT
142
- if (this.props.apiOptions.satStyling) {
143
- return null;
144
- }
145
-
146
- // Option status is shown only in review mode, or for incorrectly
147
- // answered items.
148
- if (!reviewMode && !previouslyAnswered) {
149
- return null;
150
- }
151
-
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";
72
+ type ChoicePropsWithForwardRef = {|
73
+ ...ChoiceProps,
74
+ ...WithForwardRef,
75
+ |};
172
76
 
173
- if (!finalStyles && !this.props.apiOptions.satStyling) {
174
- return null;
77
+ function Choice(props: ChoicePropsWithForwardRef): React.Node {
78
+ const {
79
+ disabled,
80
+ checked,
81
+ content,
82
+ crossedOut,
83
+ showCorrectness,
84
+ multipleSelect,
85
+ onChange,
86
+ reviewMode,
87
+ correct,
88
+ apiOptions,
89
+ previouslyAnswered,
90
+ pos,
91
+ showRationale,
92
+ rationale,
93
+ forwardedRef,
94
+ } = props;
95
+ const [isInputFocused, setIsInputFocused] = useState(false);
96
+
97
+ useEffect(() => {
98
+ if (isInputFocused && disabled) {
99
+ setIsInputFocused(false);
175
100
  }
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
- };
101
+ }, [disabled, isInputFocused, setIsInputFocused]);
193
102
 
194
103
  // Call `this.props.onChange` with the given values. Any keys that are not
195
104
  // specified will be filled in with the current value. (For example, if
@@ -198,217 +107,233 @@ class Choice extends React.Component<$FlowFixMe, State> {
198
107
  //
199
108
  // This enables us to use shorthand inside this component, while
200
109
  // 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;
110
+ function sendChange(newValues: {|
111
+ checked?: boolean,
112
+ crossedOut?: boolean,
113
+ |}) {
114
+ const updatedChecked = newValues.checked ?? checked;
115
+ const updatedCrossedOut = newValues.crossedOut ?? crossedOut;
116
+ onChange({checked: updatedChecked, crossedOut: updatedCrossedOut});
117
+ }
257
118
 
258
- return (
119
+ // HACK: while most of the styling for rendering SAT items is handled
120
+ // via aphrodite, we also need to assign normal CSS classnames here to
121
+ // special-case the coloring of MathJax formulas (see .MathJax .math in
122
+ // stylesheets/task-package/tasks.less)
123
+ const satCorrectChoice = apiOptions.satStyling && reviewMode && correct;
124
+ const satIncorrectChecked =
125
+ apiOptions.satStyling && reviewMode && !correct && checked;
126
+ const descriptionClassName = classNames(
127
+ "description",
128
+ satCorrectChoice && "sat-correct",
129
+ satIncorrectChecked && "sat-incorrect",
130
+ css(
131
+ !apiOptions.satStyling && styles.description,
132
+ apiOptions.satStyling && styles.satDescription,
133
+ satCorrectChoice && styles.satDescriptionCorrect,
134
+ satCorrectChoice && checked && styles.satDescriptionCorrectChecked,
135
+ satIncorrectChecked && styles.satDescriptionIncorrectChecked,
136
+ ),
137
+ );
138
+
139
+ const rationaleClassName = classNames(
140
+ "perseus-radio-rationale-content",
141
+ css(
142
+ styles.rationale,
143
+ !apiOptions.satStyling && styles.nonSatRationale,
144
+ apiOptions.satStyling && styles.satReviewRationale,
145
+ ),
146
+ );
147
+
148
+ // We want to show the choices as dimmed out when the choices are
149
+ // disabled. However, we don't want to do this in the SAT product and
150
+ // we also don't want to do this when we're in review mode in the
151
+ // content library.
152
+ const showDimmed =
153
+ (!apiOptions.satStyling && !reviewMode && apiOptions.readOnly) ||
154
+ crossedOut;
155
+
156
+ return (
157
+ <div
158
+ style={{
159
+ dispay: "flex",
160
+ flexDirection: "column",
161
+ }}
162
+ >
259
163
  <div
260
164
  style={{
261
- dispay: "flex",
262
- flexDirection: "column",
165
+ display: "flex",
166
+ flexDirection: "row",
167
+ width: "100%",
168
+ opacity: showDimmed ? 0.5 : 1.0,
263
169
  }}
264
170
  >
265
- <div
266
- style={{
267
- display: "flex",
268
- flexDirection: "row",
269
- width: "100%",
270
- opacity: showDimmed ? 0.5 : 1.0,
171
+ <Clickable
172
+ onClick={() => {
173
+ // If we're checking a crossed-out option, let's
174
+ // also uncross it.
175
+ sendChange({
176
+ checked: !checked,
177
+ crossedOut: false,
178
+ });
271
179
  }}
180
+ className={descriptionClassName}
181
+ disabled={
182
+ disabled ||
183
+ apiOptions.staticRender ||
184
+ apiOptions.readOnly
185
+ }
186
+ aria-label={`Select Choice ${getChoiceLetter(pos)}`}
187
+ aria-checked={checked ? "true" : "false"}
188
+ role={"checkbox"}
189
+ style={{flex: 1}}
190
+ ref={(forwardedRef: any)}
272
191
  >
273
- <Clickable
274
- onClick={() => {
275
- // If we're checking a crossed-out option, let's
276
- // also uncross it.
277
- this._sendChange({
278
- checked: true,
279
- crossedOut: false,
280
- });
281
- }}
282
- className={descriptionClassName}
283
- disabled={
284
- this.props.disabled ||
285
- this.props.apiOptions.staticRender ||
286
- this.props.apiOptions.readOnly
287
- }
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
192
+ {({hovered, focused, pressed}) => (
193
+ <div
194
+ style={{
195
+ display: "flex",
196
+ flexDirection: "row",
197
+ justifyContent: "center",
198
+ alignContent: "center",
199
+ paddingTop: Spacing.xSmall_8,
200
+ paddingBottom: Spacing.xSmall_8,
201
+ }}
202
+ >
203
+ <ChoiceIcon
204
+ pos={pos}
205
+ correct={correct}
206
+ crossedOut={crossedOut}
207
+ pressed={pressed}
208
+ focused={focused}
209
+ checked={checked}
210
+ hovered={hovered}
211
+ showCorrectness={showCorrectness}
212
+ multipleSelect={multipleSelect}
213
+ reviewMode={reviewMode}
214
+ product={
215
+ apiOptions.satStyling ? "sat" : "library"
216
+ }
217
+ primaryProductColor={
218
+ apiOptions.styling?.primaryProductColor
219
+ }
220
+ previouslyAnswered={previouslyAnswered}
221
+ />
222
+ <span
297
223
  style={{
298
- display: "flex",
299
- flexDirection: "row",
300
- justifyContent: "center",
301
- alignContent: "center",
302
- paddingTop: Spacing.xSmall_8,
303
- paddingBottom: Spacing.xSmall_8,
224
+ paddingLeft: Spacing.small_12,
225
+ textAlign: "left",
226
+ flex: 1,
227
+ paddingTop: 4,
304
228
  }}
305
229
  >
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>
230
+ <div>
231
+ <OptionStatus
232
+ checked={checked}
233
+ correct={correct}
234
+ crossedOut={crossedOut}
235
+ previouslyAnswered={previouslyAnswered}
236
+ reviewMode={reviewMode}
237
+ satStyling={apiOptions.satStyling}
238
+ />
239
+ </div>
240
+ <div>{content}</div>
241
+ </span>
242
+ </div>
396
243
  )}
397
- </div>
398
-
399
- {this.props.showRationale && (
400
- <div
401
- className={rationaleClassName}
402
- data-test-id={`perseus-radio-rationale-content-${this.props.pos}`}
244
+ </Clickable>
245
+
246
+ {apiOptions.crossOutEnabled && !reviewMode && (
247
+ <Popover
248
+ dismissEnabled
249
+ content={({close}) => (
250
+ <PopoverContent
251
+ title="Cross out"
252
+ content="Cross out option"
253
+ closeButtonVisible
254
+ actions={
255
+ <View>
256
+ <Strut size={Spacing.medium_16} />
257
+ <Button
258
+ kind="primary"
259
+ aria-label={`Cross out Choice ${getChoiceLetter(
260
+ pos,
261
+ )}`}
262
+ disabled={
263
+ apiOptions.readOnly ||
264
+ reviewMode
265
+ }
266
+ onClick={() => {
267
+ if (!crossedOut) {
268
+ // If we're crossing
269
+ // out a checked
270
+ // option, let's also
271
+ // uncheck it.
272
+ sendChange({
273
+ checked: false,
274
+ crossedOut: true,
275
+ });
276
+ } else {
277
+ sendChange({
278
+ crossedOut: false,
279
+ });
280
+ }
281
+ close();
282
+ }}
283
+ >
284
+ {crossedOut
285
+ ? "Bring back"
286
+ : "Cross out"}
287
+ </Button>
288
+ </View>
289
+ }
290
+ />
291
+ )}
403
292
  >
404
- {this.props.rationale}
405
- </div>
293
+ {({open}) => (
294
+ <Clickable
295
+ onClick={open}
296
+ aria-label={`Open menu for Choice ${getChoiceLetter(
297
+ pos,
298
+ )}`}
299
+ disabled={apiOptions.staticRender}
300
+ >
301
+ {({hovered, focused, pressed}) => (
302
+ <Icon
303
+ icon={ellipsisHorizontalIcon}
304
+ size={3}
305
+ color={Color.offBlack64}
306
+ />
307
+ )}
308
+ </Clickable>
309
+ )}
310
+ </Popover>
406
311
  )}
407
312
  </div>
408
- );
409
- }
313
+
314
+ {showRationale && (
315
+ <div
316
+ className={rationaleClassName}
317
+ data-test-id={`perseus-radio-rationale-content-${pos}`}
318
+ >
319
+ {rationale}
320
+ </div>
321
+ )}
322
+ </div>
323
+ );
410
324
  }
411
325
 
326
+ Choice.defaultProps = {
327
+ disabled: false,
328
+ checked: false,
329
+ onChange: (newValues: {checked: boolean, crossedOut: boolean}): void => {},
330
+ correct: false,
331
+ apiOptions: ApiOptions.defaults,
332
+ previouslyAnswered: false,
333
+ pos: 0,
334
+ showRationale: false,
335
+ };
336
+
412
337
  const styles = StyleSheet.create({
413
338
  description: {
414
339
  display: "inline-block",
@@ -457,4 +382,8 @@ const styles = StyleSheet.create({
457
382
  },
458
383
  });
459
384
 
460
- export default Choice;
385
+ type ExportProps = $Diff<React.ElementConfig<typeof Choice>, WithForwardRef>;
386
+
387
+ export default (React.forwardRef<ExportProps, HTMLButtonElement>(
388
+ (props, ref) => <Choice {...props} forwardedRef={ref} />,
389
+ ): React.AbstractComponent<ExportProps, HTMLButtonElement>);