@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
@@ -18,9 +18,14 @@ import PassageMarkdown from "./passage/passage-markdown.jsx";
18
18
  import type {SerializedHighlightSet} from "../components/highlighting/types.js";
19
19
  import type {ChangeableProps} from "../mixins/changeable.jsx";
20
20
  import type {PerseusPassageWidgetOptions} from "../perseus-types.js";
21
- import type {PerseusScore, WidgetExports, WidgetProps} from "../types.js";
22
-
23
- type JQueryCollection = $FlowFixMe;
21
+ import type {
22
+ PerseusScore,
23
+ WidgetExports,
24
+ WidgetInfo,
25
+ WidgetProps,
26
+ } from "../types";
27
+ import type {ParseState} from "./passage/passage-markdown.jsx";
28
+ import type {SingleASTNode} from "@khanacademy/simple-markdown";
24
29
 
25
30
  // A fake paragraph to measure the line height of the passage. In CSS we always
26
31
  // set the line height to 22 pixels, but when using the browser zoom feature,
@@ -28,8 +33,8 @@ type JQueryCollection = $FlowFixMe;
28
33
  // 22 pixels.
29
34
  class LineHeightMeasurer extends React.Component<{...}> {
30
35
  _cachedLineHeight: number;
31
- $body: JQueryCollection;
32
- $end: JQueryCollection;
36
+ body: ?HTMLDivElement;
37
+ end: ?HTMLDivElement;
33
38
 
34
39
  measureLineHeight(): number {
35
40
  if (typeof this._cachedLineHeight !== "number") {
@@ -40,15 +45,22 @@ class LineHeightMeasurer extends React.Component<{...}> {
40
45
  }
41
46
 
42
47
  forceMeasureLineHeight() {
48
+ const body = this.body;
49
+ const end = this.end;
50
+
51
+ if (!body || !end) {
52
+ return;
53
+ }
54
+
43
55
  // Add some text which magically fills an entire line.
44
- this.$body.text(" \u0080");
56
+ body.textContent = " \u0080";
45
57
 
46
58
  // Now, the line height is the difference between the top of the
47
59
  // second line and the top of the first line.
48
- this._cachedLineHeight = getLineHeightForNode(this.$body, this.$end);
60
+ this._cachedLineHeight = getLineHeightForNode(body, end);
49
61
 
50
62
  // Clear out the first line so it doesn't overlap the passage.
51
- this.$body.text("");
63
+ body.textContent = "";
52
64
  }
53
65
 
54
66
  render(): React.Node {
@@ -56,10 +68,10 @@ class LineHeightMeasurer extends React.Component<{...}> {
56
68
  <div className={css(styles.measurer)}>
57
69
  <div>
58
70
  <div
59
- ref={(e) => (this.$body = $(e))}
71
+ ref={(ref) => (this.body = ref)}
60
72
  className="paragraph"
61
73
  />
62
- <div ref={(e) => (this.$end = $(e))} />
74
+ <div ref={(ref) => (this.end = ref)} />
63
75
  </div>
64
76
  </div>
65
77
  );
@@ -87,9 +99,12 @@ type RenderProps = {|
87
99
  showLineNumbers: PerseusPassageWidgetOptions["showLineNumbers"],
88
100
  |};
89
101
 
102
+ type FindWidgetsCallback = (id: string, widgetInfo: WidgetInfo) => boolean;
103
+
90
104
  type PassageProps = {|
91
105
  ...ChangeableProps,
92
106
  ...WidgetProps<RenderProps, Rubric>,
107
+ findWidgets: (FindWidgetsCallback) => $ReadOnlyArray<Passage>,
93
108
  highlights: SerializedHighlightSet,
94
109
  |};
95
110
 
@@ -108,13 +123,6 @@ type PassageState = {|
108
123
  stylesAreApplied: boolean,
109
124
  |};
110
125
 
111
- // State kept track of by the PassageMarkdown parser.
112
- type PassageParseState = {
113
- firstQuestionRef: ?any,
114
- firstSentenceRef: ?any,
115
- ...
116
- };
117
-
118
126
  // Information about a passage reference, used in inter-widgets.
119
127
  type Reference = {
120
128
  startLine: number,
@@ -124,8 +132,9 @@ type Reference = {
124
132
  };
125
133
 
126
134
  class Passage extends React.Component<PassageProps, PassageState> {
135
+ _contentRef: ?HTMLDivElement;
136
+ _lineHeightMeasurerRef: ?LineHeightMeasurer;
127
137
  _onResize: () => {...};
128
- _lineHeightMeasurer: LineHeightMeasurer;
129
138
  _stylesAppiedTimer: TimeoutID;
130
139
 
131
140
  static defaultProps: DefaultPassageProps = {
@@ -157,7 +166,7 @@ class Passage extends React.Component<PassageProps, PassageState> {
157
166
  // height changes we expect are subpixel changes when the user
158
167
  // zooms in/out, and the only way to listen for zoom events is to
159
168
  // listen for resize events.
160
- this._lineHeightMeasurer.forceMeasureLineHeight();
169
+ this._lineHeightMeasurerRef?.forceMeasureLineHeight();
161
170
  this._updateState();
162
171
  }, 500);
163
172
  window.addEventListener("resize", this._onResize);
@@ -231,9 +240,8 @@ class Passage extends React.Component<PassageProps, PassageState> {
231
240
  }
232
241
 
233
242
  _measureLines(): number {
234
- // eslint-disable-next-line react/no-string-refs
235
- const $renderer = $(ReactDOM.findDOMNode(this.refs.content));
236
- const contentsHeight = $renderer.height();
243
+ const renderer = ReactDOM.findDOMNode(this._contentRef);
244
+ const contentsHeight: number = $(renderer).height();
237
245
  const lineHeight = this._getLineHeight();
238
246
  const nLines = Math.round(contentsHeight / lineHeight);
239
247
  return nLines;
@@ -259,7 +267,7 @@ class Passage extends React.Component<PassageProps, PassageState> {
259
267
  }
260
268
 
261
269
  _getLineHeight(): number {
262
- return this._lineHeightMeasurer.measureLineHeight();
270
+ return this._lineHeightMeasurerRef?.measureLineHeight() || 0;
263
271
  }
264
272
 
265
273
  getLineCount(): number {
@@ -275,11 +283,15 @@ class Passage extends React.Component<PassageProps, PassageState> {
275
283
  * These are functions to support the passage refs inter-widgets feature
276
284
  * where other widgets can fetch the line numbers of a reference inside of
277
285
  * a passage.
286
+ *
287
+ * todo(matthewc): The refs are created by PassageMarkdown's refStart and refEnd,
288
+ * somehow bubbling up to Passage's `this.refs`. This runs against
289
+ * current best practices for refs by using string refs, but also
290
+ * by breaking our expectation of explicit data flow.
278
291
  */
279
292
 
280
293
  _getStartRefLineNumber(referenceNumber: number): ?number {
281
294
  const refRef = PassageMarkdown.START_REF_PREFIX + referenceNumber;
282
- // eslint-disable-next-line react/no-string-refs
283
295
  const ref = this.refs[refRef];
284
296
  if (!ref) {
285
297
  return null;
@@ -305,7 +317,6 @@ class Passage extends React.Component<PassageProps, PassageState> {
305
317
 
306
318
  _getEndRefLineNumber(referenceNumber: number): ?number {
307
319
  const refRef = PassageMarkdown.END_REF_PREFIX + referenceNumber;
308
- // eslint-disable-next-line react/no-string-refs
309
320
  const ref = this.refs[refRef];
310
321
  if (!ref) {
311
322
  return null;
@@ -320,8 +331,8 @@ class Passage extends React.Component<PassageProps, PassageState> {
320
331
  // use the ref itself.
321
332
  $refText = $ref;
322
333
  }
323
- const height = $refText.height();
324
- const vPos = $refText.offset().top;
334
+ const height: number = $refText.height();
335
+ const vPos: number = $refText.offset().top;
325
336
 
326
337
  let line = this._convertPosToLineNumber(vPos + height);
327
338
  if (height === 0) {
@@ -338,9 +349,8 @@ class Passage extends React.Component<PassageProps, PassageState> {
338
349
  }
339
350
 
340
351
  _convertPosToLineNumber(absoluteVPos: number): number {
341
- // eslint-disable-next-line react/no-string-refs
342
- const $content = $(ReactDOM.findDOMNode(this.refs.content));
343
- const relativeVPos = absoluteVPos - $content.offset().top;
352
+ const content = ReactDOM.findDOMNode(this._contentRef);
353
+ const relativeVPos = absoluteVPos - $(content).offset().top;
344
354
  const lineHeight = this._getLineHeight();
345
355
 
346
356
  const line = Math.round(relativeVPos / lineHeight);
@@ -349,7 +359,6 @@ class Passage extends React.Component<PassageProps, PassageState> {
349
359
 
350
360
  _getRefContent(referenceNumber: number): ?string {
351
361
  const refRef = PassageMarkdown.START_REF_PREFIX + referenceNumber;
352
- // eslint-disable-next-line react/no-string-refs
353
362
  const ref = this.refs[refRef];
354
363
  if (!ref) {
355
364
  return null;
@@ -403,7 +412,7 @@ class Passage extends React.Component<PassageProps, PassageState> {
403
412
  * Functions to render the passage widget.
404
413
  */
405
414
 
406
- _renderInstructions(parseState: PassageParseState): React.Element<any> {
415
+ _renderInstructions(parseState: ParseState): React.Element<any> {
407
416
  const firstQuestionNumber = parseState.firstQuestionRef;
408
417
  const firstSentenceRef = parseState.firstSentenceRef;
409
418
 
@@ -443,7 +452,7 @@ class Passage extends React.Component<PassageProps, PassageState> {
443
452
  return JIPT.useJIPT && this.props.passageText.indexOf("crwdns") !== -1;
444
453
  }
445
454
 
446
- _renderContent(parsed: $ReadOnlyArray<any>): React.Element<any> {
455
+ _renderContent(parsed: Array<SingleASTNode>): React.Element<any> {
447
456
  // Wait until Aphrodite styles are applied before enabling highlights,
448
457
  // so that we measure the correct positions.
449
458
  const enabled = this.state.stylesAreApplied;
@@ -459,13 +468,9 @@ class Passage extends React.Component<PassageProps, PassageState> {
459
468
  }
460
469
  serializedHighlights={this.props.highlights}
461
470
  >
462
- {/* eslint-disable-next-line react/no-string-refs */}
463
- <div ref="content">
471
+ <div ref={(ref) => (this._contentRef = ref)}>
464
472
  <LineHeightMeasurer
465
- // TODO(mdr): We found a new Flow error when upgrading:
466
- // "e (null) This type is incompatible with this._lineHeightMeasurer (LineHeightMeasurer)"
467
- // $FlowFixMe[incompatible-type](0.52.0->0.53.0)
468
- ref={(e) => (this._lineHeightMeasurer = e)}
473
+ ref={(ref) => (this._lineHeightMeasurerRef = ref)}
469
474
  />
470
475
  {PassageMarkdown.output(parsed)}
471
476
  </div>
@@ -479,14 +484,14 @@ class Passage extends React.Component<PassageProps, PassageState> {
479
484
  return !isEmpty;
480
485
  }
481
486
 
482
- _renderFootnotes(): React.Element<any> {
487
+ _renderFootnotes(): React.Node {
483
488
  const rawContent = this.props.footnotes;
484
489
  const parsed = PassageMarkdown.parse(rawContent);
485
490
  return PassageMarkdown.output(parsed);
486
491
  }
487
492
 
488
493
  render(): React.Element<"div"> {
489
- let lineNumbers;
494
+ let lineNumbers: $ReadOnlyArray<React.Node>;
490
495
  const nLines = this.state.nLines;
491
496
  if (this.props.showLineNumbers && nLines) {
492
497
  // lineN is the line number in the current passage
@@ -507,10 +512,7 @@ class Passage extends React.Component<PassageProps, PassageState> {
507
512
  });
508
513
  }
509
514
 
510
- const parseState: PassageParseState = {
511
- firstSentenceRef: null,
512
- firstQuestionRef: null,
513
- };
515
+ const parseState: ParseState = PassageMarkdown.getInitialParseState();
514
516
 
515
517
  // Replace the vertical double quote characters quoting text with
516
518
  // an unicode left and right double quote characters. This would
@@ -521,7 +523,7 @@ class Passage extends React.Component<PassageProps, PassageState> {
521
523
  re,
522
524
  "\u201c$2\u201d",
523
525
  );
524
- const parsedContent: $ReadOnlyArray<*> = PassageMarkdown.parse(
526
+ const parsedContent = PassageMarkdown.parse(
525
527
  doubleQuoteParsedContent,
526
528
  parseState,
527
529
  );
@@ -1,40 +1,187 @@
1
1
  // @flow
2
+ import {action} from "@storybook/addon-actions";
2
3
  import * as React from "react";
3
4
 
4
- import Widget from "../base-radio.jsx";
5
+ import {generateChoice} from "../../__testdata__/base-radio_testdata.js";
6
+ import BaseRadio from "../base-radio.jsx";
5
7
 
6
- type StoryArgs = {||};
8
+ type StoryArgs = {|
9
+ multipleSelect: boolean,
10
+ editMode: boolean,
11
+ countChoices: boolean,
12
+ |};
7
13
 
8
14
  type Story = {|
9
15
  title: string,
16
+ args: StoryArgs,
10
17
  |};
11
18
 
12
19
  export default ({
13
20
  title: "Perseus/Widgets/Radio/Base Radio",
21
+ args: {
22
+ multipleSelect: false,
23
+ editMode: false,
24
+ countChoices: false,
25
+ },
14
26
  }: Story);
15
27
 
16
- const defaultObject = {
28
+ const defaultProps = {
17
29
  apiOptions: {
18
30
  satStyling: false,
19
- styling: {
20
- radioStyleVersion: "final",
21
- },
22
31
  },
23
32
  choices: [
24
- {
33
+ generateChoice({
25
34
  content: "Content 1",
26
- originalIndex: 0,
27
- },
28
- {
35
+ }),
36
+ generateChoice({
29
37
  content: "Content 2",
30
- originalIndex: 1,
31
- },
38
+ }),
39
+ generateChoice({
40
+ content: "Content 3",
41
+ correct: true,
42
+ }),
43
+ generateChoice({
44
+ isNoneOfTheAbove: true,
45
+ }),
32
46
  ],
33
- onChange: () => {},
34
- trackInteraction: () => {},
35
- widgetId: "test-widget",
47
+ deselectEnabled: false,
48
+ editMode: false,
49
+ labelWrap: false,
50
+ countChoices: false,
51
+ numCorrect: 1,
52
+ multipleSelect: false,
53
+
54
+ // A callback indicating that this choice has changed. Its argument is
55
+ // an object with two keys: `checked` and `crossedOut`. Each contains
56
+ // an array of boolean values, specifying the new checked and
57
+ // crossed-out value of each choice.
58
+ onChange: action("changed"),
59
+
60
+ // Whether this widget was the most recently used widget in this
61
+ // Renderer. Determines whether we'll auto-scroll the page upon
62
+ // entering review mode.
63
+ isLastUsedWidget: false,
64
+ };
65
+
66
+ export const Interactive = (args: StoryArgs): React.Node => {
67
+ const overwrittenProps = {...defaultProps, ...args};
68
+ return <BaseRadio {...overwrittenProps} />;
69
+ };
70
+
71
+ export const DefaultSingleSelect = (args: StoryArgs): React.Node => {
72
+ const overwrittenProps = {...defaultProps, multipleSelect: false};
73
+ return <BaseRadio {...overwrittenProps} />;
74
+ };
75
+
76
+ export const DefaultMultipleSelect = (args: StoryArgs): React.Node => {
77
+ const overwrittenProps = {...defaultProps, multipleSelect: true};
78
+ return <BaseRadio {...overwrittenProps} />;
79
+ };
80
+
81
+ export const DefaultMultipleSelectCountChoices = (
82
+ args: StoryArgs,
83
+ ): React.Node => {
84
+ const overwrittenProps = {
85
+ ...defaultProps,
86
+ multipleSelect: true,
87
+ numCorrect: 2,
88
+ countChoices: true,
89
+ };
90
+ return <BaseRadio {...overwrittenProps} />;
91
+ };
92
+
93
+ export const SingleSelected = (args: StoryArgs): React.Node => {
94
+ const choices = Array(4)
95
+ .fill(null)
96
+ .map((_, i) => generateChoice({content: `Choice ${i + 1}`}));
97
+ choices[1].checked = true;
98
+
99
+ const overwrittenProps = {...defaultProps, multipleSelect: false, choices};
100
+ return <BaseRadio {...overwrittenProps} />;
36
101
  };
37
102
 
38
- export const DefaultSettings = (args: StoryArgs): React.Node => {
39
- return <Widget {...defaultObject} />;
103
+ export const MultipleSelected = (args: StoryArgs): React.Node => {
104
+ const choices = Array(4)
105
+ .fill(null)
106
+ .map((_, i) => generateChoice({content: `Choice ${i + 1}`}));
107
+ choices[1].checked = true;
108
+ choices[2].checked = true;
109
+
110
+ const overwrittenProps = {...defaultProps, multipleSelect: true, choices};
111
+ return <BaseRadio {...overwrittenProps} />;
112
+ };
113
+
114
+ export const SingleKitchenSink = (args: StoryArgs): React.Node => {
115
+ const choices = Array(4)
116
+ .fill(null)
117
+ .map((_, i) => {
118
+ const choice = generateChoice({
119
+ content: `Choice ${i + 1}`,
120
+ rationale: "This is a neat rationale",
121
+ hasRationale: true,
122
+ showRationale: true,
123
+ correct: false,
124
+ showCorrectness: true,
125
+ });
126
+
127
+ return choice;
128
+ });
129
+ choices[1].checked = true;
130
+ choices[2].correct = true;
131
+
132
+ const rubricChoices = choices.map(({correct}) => ({
133
+ // note(matthew): reviewModeRubric.choices requires content,
134
+ // but I don't see how it's getting used and Flow gets mad
135
+ // when I use choice.content because it's not a string.
136
+ // reviewModeRubric could probably use a look over.
137
+ content: "",
138
+ correct,
139
+ }));
140
+
141
+ const overwrittenProps = {
142
+ ...defaultProps,
143
+ multipleSelect: false,
144
+ reviewModeRubric: {choices: rubricChoices},
145
+ choices,
146
+ };
147
+ return <BaseRadio {...overwrittenProps} />;
148
+ };
149
+
150
+ export const MultipleKitchenSink = (args: StoryArgs): React.Node => {
151
+ const choices = Array(4)
152
+ .fill(null)
153
+ .map((_, i) => {
154
+ const choice = generateChoice({
155
+ content: `Choice ${i + 1}`,
156
+ rationale: "This is a neat rationale",
157
+ hasRationale: true,
158
+ showRationale: true,
159
+ correct: false,
160
+ showCorrectness: true,
161
+ });
162
+
163
+ return choice;
164
+ });
165
+ choices[1].checked = true;
166
+ choices[2].checked = true;
167
+ choices[2].correct = true;
168
+ choices[3].correct = true;
169
+
170
+ const rubricChoices = choices.map((c) => ({
171
+ // note(matthew): reviewModeRubric.choices requires content,
172
+ // but I don't see how it's getting used and Flow gets mad
173
+ // when I use choice.content because it's not a string.
174
+ // reviewModeRubric could probably use a look over.
175
+ content: "",
176
+ correct: c.correct,
177
+ }));
178
+
179
+ const overwrittenProps = {
180
+ ...defaultProps,
181
+ multipleSelect: true,
182
+ numCorrect: 2,
183
+ reviewModeRubric: {choices: rubricChoices},
184
+ choices,
185
+ };
186
+ return <BaseRadio {...overwrittenProps} />;
40
187
  };