@khanacademy/perseus 1.0.0 → 1.1.1

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 (49) hide show
  1. package/CHANGELOG.md +19 -0
  2. package/dist/es/index.css +17 -8
  3. package/dist/es/index.js +258 -223
  4. package/dist/es/index.js.map +1 -1
  5. package/dist/index.css +17 -8
  6. package/dist/index.js +261 -225
  7. package/dist/index.js.map +1 -1
  8. package/package.json +1 -1
  9. package/src/components/__stories__/input-with-examples.stories.jsx +1 -0
  10. package/src/components/input-with-examples.jsx +41 -24
  11. package/src/components/number-input.jsx +2 -2
  12. package/src/components/simple-keypad-input.jsx +18 -21
  13. package/src/components/text-input.jsx +5 -3
  14. package/src/index.js +1 -1
  15. package/src/multi-items/__tests__/__snapshots__/multi-renderer_test.js.snap +356 -65
  16. package/src/multi-items/__tests__/multi-renderer_test.js +9 -9
  17. package/src/perseus-types.js +18 -7
  18. package/src/styles/perseus-renderer.less +8 -2
  19. package/src/styles/widgets/matrix.less +2 -0
  20. package/src/styles/widgets/passage.less +9 -5
  21. package/src/util/answer-types.js +2 -3
  22. package/src/util/math.js +3 -10
  23. package/src/widgets/__stories__/numeric-input.stories.jsx +94 -7
  24. package/src/widgets/__tests__/__snapshots__/definition_test.js.snap +4 -4
  25. package/src/widgets/__tests__/__snapshots__/graded-group-set-jipt_test.jsx.snap +15 -6
  26. package/src/widgets/__tests__/__snapshots__/graded-group-set_test.jsx.snap +5 -2
  27. package/src/widgets/__tests__/__snapshots__/graded-group_test.jsx.snap +4 -1
  28. package/src/widgets/__tests__/__snapshots__/group_test.jsx.snap +228 -67
  29. package/src/widgets/__tests__/__snapshots__/matrix_test.jsx.snap +24 -24
  30. package/src/widgets/__tests__/__snapshots__/numeric-input_test.jsx.snap +4 -4
  31. package/src/widgets/__tests__/__snapshots__/radio_test.jsx.snap +2581 -679
  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 +55 -7
  36. package/src/widgets/__tests__/radio/choice_test.jsx +164 -63
  37. package/src/widgets/__tests__/radio_test.jsx +39 -15
  38. package/src/widgets/definition.jsx +1 -1
  39. package/src/widgets/graded-group.jsx +5 -1
  40. package/src/widgets/group.jsx +84 -102
  41. package/src/widgets/input-number.jsx +6 -1
  42. package/src/widgets/matrix.jsx +17 -1
  43. package/src/widgets/numeric-input.jsx +99 -86
  44. package/src/widgets/radio/base-radio.jsx +3 -1
  45. package/src/widgets/radio/choice-a11y-text.js +62 -0
  46. package/src/widgets/radio/choice-icon/choice-icon.jsx +0 -68
  47. package/src/widgets/radio/choice-icon/library-choice-icon.jsx +5 -10
  48. package/src/widgets/radio/choice-icon/sat-choice-icon.jsx +3 -12
  49. package/src/widgets/radio/choice.jsx +46 -3
@@ -25,7 +25,7 @@ import type {
25
25
  import type {APIOptions} from "../../types.js";
26
26
 
27
27
  const selectOption = (index: number) => {
28
- const options = screen.getAllByRole("checkbox");
28
+ const options = screen.getAllByRole("radio");
29
29
 
30
30
  // element that is null/undefined (ie. if the index is invalid) so we
31
31
  // manually check and throw here to protect future me, and others :)
@@ -101,8 +101,7 @@ describe("single-choice question", () => {
101
101
  it("should accept the right answer (touch)", () => {
102
102
  // Arrange
103
103
  const {renderer} = renderQuestion(question, apiOptions);
104
- const correctRadio =
105
- screen.getAllByRole("checkbox")[correct];
104
+ const correctRadio = screen.getAllByRole("radio")[correct];
106
105
 
107
106
  // Act
108
107
  fireEvent.touchStart(correctRadio);
@@ -157,7 +156,7 @@ describe("single-choice question", () => {
157
156
  renderer.deselectIncorrectSelectedChoices();
158
157
 
159
158
  // Assert
160
- screen.getAllByRole("checkbox").forEach((r) => {
159
+ screen.getAllByRole("radio").forEach((r) => {
161
160
  expect(r).not.toBeChecked();
162
161
  });
163
162
  });
@@ -181,9 +180,14 @@ describe("single-choice question", () => {
181
180
 
182
181
  // Assert
183
182
  // Everything's read-only so no selections made
184
- screen.getAllByRole("checkbox").forEach((r) => {
185
- expect(r).toHaveAttribute("aria-disabled", "true");
186
- });
183
+ // Note(TB): The visual buttons are hidden from screen readers
184
+ // so they need to be identified as hidden
185
+ // The visual button has the aria attributes
186
+ screen
187
+ .getAllByRole("button", {hidden: true})
188
+ .forEach((r) => {
189
+ expect(r).toHaveAttribute("aria-disabled", "true");
190
+ });
187
191
  });
188
192
  },
189
193
  );
@@ -194,11 +198,18 @@ describe("single-choice question", () => {
194
198
 
195
199
  // Act
196
200
  userEvent.tab();
197
- expect(screen.getAllByRole("checkbox")[0]).toHaveFocus();
201
+ // Note(TB): The visual buttons are hidden from screen readers
202
+ // so they need to be identified as hidden;
203
+ // cannot access screen reader buttons via tabbing
204
+ expect(
205
+ screen.getAllByRole("button", {hidden: true})[0],
206
+ ).toHaveFocus();
198
207
  userEvent.tab();
199
208
 
200
209
  // Assert
201
- expect(screen.getAllByRole("checkbox")[1]).toHaveFocus();
210
+ expect(
211
+ screen.getAllByRole("button", {hidden: true})[1],
212
+ ).toHaveFocus();
202
213
  });
203
214
 
204
215
  it("should be able to navigate up by keyboard", () => {
@@ -207,13 +218,23 @@ describe("single-choice question", () => {
207
218
 
208
219
  // Act
209
220
  userEvent.tab();
210
- expect(screen.getAllByRole("checkbox")[0]).toHaveFocus();
221
+ // Note(TB): The visual buttons are hidden from screen readers
222
+ // so they need to be identified as hidden
223
+ expect(
224
+ screen.getAllByRole("button", {hidden: true})[0],
225
+ ).toHaveFocus();
226
+
211
227
  userEvent.tab();
212
- expect(screen.getAllByRole("checkbox")[1]).toHaveFocus();
228
+ expect(
229
+ screen.getAllByRole("button", {hidden: true})[1],
230
+ ).toHaveFocus();
231
+
213
232
  userEvent.tab({shift: true});
214
233
 
215
234
  // Assert
216
- expect(screen.getAllByRole("checkbox")[0]).toHaveFocus();
235
+ expect(
236
+ screen.getAllByRole("button", {hidden: true})[0],
237
+ ).toHaveFocus();
217
238
  });
218
239
 
219
240
  it("should be able to navigate through 'None of the above' choice by keyboard", () => {
@@ -229,7 +250,11 @@ describe("single-choice question", () => {
229
250
  userEvent.tab();
230
251
 
231
252
  // Assert
232
- expect(screen.getAllByRole("checkbox")[2]).toHaveFocus();
253
+ // Note(TB): The visual buttons are hidden from screen readers
254
+ // so they need to be identified as hidden
255
+ expect(
256
+ screen.getAllByRole("button", {hidden: true})[2],
257
+ ).toHaveFocus();
233
258
  });
234
259
 
235
260
  it.each([
@@ -251,7 +276,7 @@ describe("single-choice question", () => {
251
276
  // We click on the first item, which was the second (index == 1)
252
277
  // item in the original choices. But because of enforced ordering,
253
278
  // it is now at the top of the list (and thus our correct answer).
254
- userEvent.click(screen.getAllByRole("checkbox")[0]);
279
+ userEvent.click(screen.getAllByRole("radio")[0]);
255
280
 
256
281
  // Assert
257
282
  const items = screen.getAllByRole("listitem");
@@ -393,7 +418,6 @@ describe("single-choice question", () => {
393
418
 
394
419
  it("should cross-out selection and dismiss button when clicked", async () => {
395
420
  // Arrange
396
-
397
421
  renderQuestion(question, crossOutApiOptions);
398
422
  userEvent.click(
399
423
  screen.getByRole("button", {
@@ -72,7 +72,7 @@ class Definition extends React.Component<DefinitionProps> {
72
72
  >
73
73
  <span className="perseus-widget-definition">
74
74
  <Button
75
- size="small"
75
+ size="medium"
76
76
  kind="tertiary"
77
77
  onClick={() => {
78
78
  this.props.trackInteraction();
@@ -294,7 +294,11 @@ class GradedGroup extends React.Component<Props, State> {
294
294
  {gradeStatus}
295
295
  </div>
296
296
  )}
297
- {!apiOptions.isMobile && <p>{this.state.message}</p>}
297
+ {!apiOptions.isMobile && (
298
+ <p role="status" aria-live="polite">
299
+ {this.state.message}
300
+ </p>
301
+ )}
298
302
  {!apiOptions.isMobile && (
299
303
  <Button
300
304
  kind="secondary"
@@ -1,16 +1,17 @@
1
- /* eslint-disable react/sort-comp */
2
1
  // @flow
3
2
  import {linterContextDefault} from "@khanacademy/perseus-linter";
4
3
  import classNames from "classnames";
5
4
  import * as React from "react";
6
- import _ from "underscore";
7
5
 
8
6
  import * as Changeable from "../mixins/changeable.jsx";
9
7
  import {ApiOptions} from "../perseus-api.jsx";
10
8
  import Renderer from "../renderer.jsx";
11
9
 
12
10
  import type {PerseusGroupWidgetOptions} from "../perseus-types.js";
11
+ import type {Widget} from "../renderer.jsx";
13
12
  import type {
13
+ APIOptions,
14
+ ChangeFn,
14
15
  FocusPath,
15
16
  PerseusScore,
16
17
  WidgetExports,
@@ -28,6 +29,8 @@ type DefaultProps = {|
28
29
  |};
29
30
 
30
31
  class Group extends React.Component<Props> {
32
+ rendererRef: ?Renderer;
33
+
31
34
  static defaultProps: DefaultProps = {
32
35
  content: "",
33
36
  widgets: {},
@@ -42,24 +45,76 @@ class Group extends React.Component<Props> {
42
45
  this.forceUpdate();
43
46
  }
44
47
 
48
+ change: ChangeFn = (...args) => {
49
+ return Changeable.change.apply(this, args);
50
+ };
51
+
52
+ getUserInput: () => $FlowFixMe = () => {
53
+ return this.rendererRef?.getUserInput();
54
+ };
55
+
56
+ getSerializedState: () => $FlowFixMe = () => {
57
+ return this.rendererRef?.getSerializedState();
58
+ };
59
+
60
+ restoreSerializedState: ($FlowFixMe, $FlowFixMe) => null = (
61
+ state,
62
+ callback,
63
+ ) => {
64
+ this.rendererRef?.restoreSerializedState(state, callback);
65
+ // Tell our renderer that we have no props to change
66
+ // (all our changes were in state):
67
+ return null;
68
+ };
69
+
70
+ simpleValidate: (Rubric) => ?PerseusScore = (rubric) => {
71
+ return this.rendererRef?.score();
72
+ };
73
+
74
+ // Mobile API:
75
+ getInputPaths: () => ?$ReadOnlyArray<FocusPath> = () => {
76
+ return this.rendererRef?.getInputPaths();
77
+ };
78
+
79
+ setInputValue: (FocusPath, string, () => mixed) => void = (
80
+ path,
81
+ newValue,
82
+ callback,
83
+ ) => {
84
+ return this.rendererRef?.setInputValue(path, newValue, callback);
85
+ };
86
+
87
+ focus: () => ?boolean = () => {
88
+ return this.rendererRef?.focus();
89
+ };
90
+
91
+ focusInputPath: (FocusPath) => void = (path) => {
92
+ this.rendererRef?.focusPath(path);
93
+ };
94
+
95
+ blurInputPath: (FocusPath) => void = (path) => {
96
+ this.rendererRef?.blurPath(path);
97
+ };
98
+
99
+ showRationalesForCurrentlySelectedChoices: () => void = () => {
100
+ this.rendererRef?.showRationalesForCurrentlySelectedChoices();
101
+ };
102
+
45
103
  render(): React.Node {
46
- const apiOptions = _.extend(
47
- {},
48
- ApiOptions.defaults,
49
- this.props.apiOptions,
50
- {
51
- // Api Rewriting to support correct onFocus/onBlur
52
- // events for the mobile API
53
- onFocusChange: (newFocus, oldFocus) => {
54
- if (oldFocus) {
55
- this.props.onBlur(oldFocus);
56
- }
57
- if (newFocus) {
58
- this.props.onFocus(newFocus);
59
- }
60
- },
104
+ const apiOptions: APIOptions = {
105
+ ...ApiOptions.defaults,
106
+ ...this.props.apiOptions,
107
+ // Api Rewriting to support correct onFocus/onBlur
108
+ // events for the mobile API
109
+ onFocusChange: (newFocus, oldFocus) => {
110
+ if (oldFocus) {
111
+ this.props.onBlur(oldFocus);
112
+ }
113
+ if (newFocus) {
114
+ this.props.onFocus(newFocus);
115
+ }
61
116
  },
62
- );
117
+ };
63
118
 
64
119
  // Allow a problem number annotation to be added.
65
120
  // This is cyclical and should probably be reconsidered. In order to
@@ -69,7 +124,9 @@ class Group extends React.Component<Props> {
69
124
  // give ourselves number -1. To combat this, we forceUpdate in
70
125
  // componentDidMount so that we can number ourselves properly. But,
71
126
  // really we should have a more unidirectional flow. TODO(marcia): fix.
72
- const number = _.indexOf(this.props.findWidgets("group"), this);
127
+ const groupWidgets: $ReadOnlyArray<Widget> =
128
+ this.props.findWidgets("group");
129
+ const number: number = groupWidgets.indexOf(this);
73
130
  const problemNumComponent = this.props.apiOptions.groupAnnotator(
74
131
  number,
75
132
  this.props.widgetId,
@@ -81,15 +138,12 @@ class Group extends React.Component<Props> {
81
138
  // alert our renderer (our parent) of the fact that some interaction
82
139
  // has occurred.
83
140
  const onInteractWithWidget = (id) => {
84
- // eslint-disable-next-line react/no-string-refs
85
- if (this.refs.renderer) {
86
- // eslint-disable-next-line react/no-string-refs
87
- this.change("widgets", this.refs.renderer.props.widgets);
141
+ if (this.rendererRef) {
142
+ this.change("widgets", this.rendererRef.props.widgets);
88
143
  }
89
144
  };
90
145
 
91
- // eslint-disable-next-line react/no-string-refs
92
- const score = this.refs.renderer && this.refs.renderer.score();
146
+ const score = this.rendererRef?.score();
93
147
  const isValid = score && score.type !== "invalid";
94
148
  const isInvalid = score && score.type === "invalid";
95
149
 
@@ -104,18 +158,11 @@ class Group extends React.Component<Props> {
104
158
  })}
105
159
  >
106
160
  {problemNumComponent}
107
- {/**
108
- * We're passing a bunch of extra props to Renderer here that it
109
- * doesn't need. We should replace {...this.props} with the individual
110
- * props that are needed.
111
- * TODO(FEI-4034): Only pass what the Renderer expects.
112
- */}
113
- {/* $FlowFixMe[prop-missing] */}
114
- {/* $FlowFixMe[incompatible-type] */}
115
161
  <Renderer
116
- {...this.props}
117
- // eslint-disable-next-line react/no-string-refs
118
- ref="renderer"
162
+ content={this.props.content}
163
+ widgets={this.props.widgets}
164
+ images={this.props.images}
165
+ ref={(ref) => (this.rendererRef = ref)}
119
166
  apiOptions={apiOptions}
120
167
  findExternalWidgets={this.props.findWidgets}
121
168
  reviewMode={!!this.props.reviewModeRubric}
@@ -129,78 +176,13 @@ class Group extends React.Component<Props> {
129
176
  </div>
130
177
  );
131
178
  }
132
-
133
- change: ($FlowFixMe, $FlowFixMe, $FlowFixMe) => $FlowFixMe = (...args) => {
134
- return Changeable.change.apply(this, args);
135
- };
136
-
137
- getUserInput: () => $FlowFixMe = () => {
138
- // eslint-disable-next-line react/no-string-refs
139
- return this.refs.renderer.getUserInput();
140
- };
141
-
142
- getSerializedState: () => $FlowFixMe = () => {
143
- // eslint-disable-next-line react/no-string-refs
144
- return this.refs.renderer.getSerializedState();
145
- };
146
-
147
- restoreSerializedState: ($FlowFixMe, $FlowFixMe) => $FlowFixMe = (
148
- state,
149
- callback,
150
- ) => {
151
- // eslint-disable-next-line react/no-string-refs
152
- this.refs.renderer.restoreSerializedState(state, callback);
153
- // Tell our renderer that we have no props to change
154
- // (all our changes were in state):
155
- return null;
156
- };
157
-
158
- simpleValidate: (Rubric) => PerseusScore = (rubric) => {
159
- // eslint-disable-next-line react/no-string-refs
160
- return this.refs.renderer.score();
161
- };
162
-
163
- // Mobile API:
164
- getInputPaths: () => $ReadOnlyArray<FocusPath> = () => {
165
- // eslint-disable-next-line react/no-string-refs
166
- return this.refs.renderer.getInputPaths();
167
- };
168
-
169
- setInputValue: (FocusPath, string, $FlowFixMe) => void = (
170
- path,
171
- newValue,
172
- cb,
173
- ) => {
174
- // eslint-disable-next-line react/no-string-refs
175
- return this.refs.renderer.setInputValue(path, newValue, cb);
176
- };
177
-
178
- focus: () => ?boolean = () => {
179
- // eslint-disable-next-line react/no-string-refs
180
- return this.refs.renderer.focus();
181
- };
182
-
183
- focusInputPath: (FocusPath) => void = (path) => {
184
- // eslint-disable-next-line react/no-string-refs
185
- this.refs.renderer.focusPath(path);
186
- };
187
-
188
- blurInputPath: (FocusPath) => void = (path) => {
189
- // eslint-disable-next-line react/no-string-refs
190
- this.refs.renderer.blurPath(path);
191
- };
192
-
193
- showRationalesForCurrentlySelectedChoices: () => void = () => {
194
- // eslint-disable-next-line react/no-string-refs
195
- this.refs.renderer.showRationalesForCurrentlySelectedChoices();
196
- };
197
179
  }
198
180
 
199
181
  const traverseChildWidgets = function (
200
182
  props: $FlowFixMe,
201
183
  traverseRenderer: $FlowFixMe,
202
184
  ): $FlowFixMe {
203
- return _.extend({}, props, traverseRenderer(props));
185
+ return {...props, ...traverseRenderer(props)};
204
186
  };
205
187
 
206
188
  export default ({
@@ -332,8 +332,13 @@ class InputNumber extends React.Component<Props> {
332
332
  if (rubric.answerType == null) {
333
333
  rubric.answerType = "number";
334
334
  }
335
+
336
+ // note(matthewc): this will get immediately parsed again by
337
+ // `KhanAnswerTypes.number.convertToPredicate`, but a string is
338
+ // expected here
339
+ const stringValue = `${rubric.value}`;
335
340
  const val = KhanAnswerTypes.number.createValidatorFunctional(
336
- rubric.value,
341
+ stringValue,
337
342
  {
338
343
  simplify: rubric.simplify,
339
344
  inexact: rubric.inexact || undefined,
@@ -3,6 +3,7 @@
3
3
  // @flow
4
4
  import {linterContextDefault} from "@khanacademy/perseus-linter";
5
5
  import * as i18n from "@khanacademy/wonder-blocks-i18n";
6
+ import {StyleSheet} from "aphrodite";
6
7
  import classNames from "classnames";
7
8
  import * as React from "react";
8
9
  import ReactDOM from "react-dom";
@@ -324,8 +325,23 @@ class Matrix extends React.Component<Props, State> {
324
325
  <NumberInput {...inputProps} />
325
326
  );
326
327
  } else {
328
+ const updatedProps = {
329
+ ...inputProps,
330
+ style: StyleSheet.create({
331
+ // eslint-disable-next-line react-native/no-unused-styles
332
+ input: {
333
+ ...inputProps.style,
334
+ display: "inline-block",
335
+ padding: 0,
336
+ backgroundColor: outside
337
+ ? "#f3f3f3"
338
+ : "#fff",
339
+ },
340
+ }).input,
341
+ };
342
+
327
343
  MatrixInput = (
328
- <TextInput {...inputProps} />
344
+ <TextInput {...updatedProps} />
329
345
  );
330
346
  }
331
347
  return (