@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
@@ -0,0 +1,302 @@
1
+ // @flow
2
+
3
+ import {render, screen} from "@testing-library/react";
4
+ import userEvent from "@testing-library/user-event";
5
+ import * as React from "react";
6
+
7
+ import "@testing-library/jest-dom"; // Imports custom mathers
8
+
9
+ import Choice from "../../radio/choice.jsx";
10
+
11
+ function renderChoice(options) {
12
+ const defaultOptions = {
13
+ checked: false,
14
+ rationale: "This is a good rationale",
15
+ content: "This is a possible choice",
16
+ correct: true,
17
+ disabled: false,
18
+ pos: 0,
19
+ reviewMode: false,
20
+ showRationale: false,
21
+ showCorrectness: false,
22
+ multipleSelect: false,
23
+ crossedOut: false,
24
+ previouslyAnswered: false,
25
+ ref: {current: null},
26
+ };
27
+
28
+ const overwrittenOptions = {...defaultOptions, ...options};
29
+
30
+ return render(<Choice {...overwrittenOptions} />);
31
+ }
32
+
33
+ describe("all choice options", () => {
34
+ it("renders rationale", () => {
35
+ // Arrange / Act
36
+ renderChoice({showRationale: true});
37
+
38
+ // Assert
39
+ expect(screen.getByText("This is a good rationale")).toBeVisible();
40
+ });
41
+
42
+ it("shows correct when in review mode", () => {
43
+ // Arrange / Act
44
+ renderChoice({
45
+ checked: false,
46
+ correct: true,
47
+ showCorrectness: true,
48
+ reviewMode: true,
49
+ });
50
+
51
+ // Assert
52
+ expect(screen.getByText("Correct")).toBeVisible();
53
+ });
54
+
55
+ it("shows incorrect when in review mode", () => {
56
+ // Arrange / Act
57
+ renderChoice({
58
+ checked: false,
59
+ correct: false,
60
+ showCorrectness: true,
61
+ reviewMode: true,
62
+ });
63
+
64
+ // Assert
65
+ expect(screen.getByText("Incorrect")).toBeVisible();
66
+ });
67
+
68
+ it("shows correct a11y text when in review mode", () => {
69
+ // Arrange / Act
70
+ renderChoice({
71
+ checked: false,
72
+ correct: true,
73
+ showCorrectness: true,
74
+ reviewMode: true,
75
+ });
76
+
77
+ // Assert
78
+ expect(
79
+ screen.getByText("(Choice A, Correct) This is a possible choice"),
80
+ ).toBeVisible();
81
+ });
82
+
83
+ it("shows correct selected when in review mode", () => {
84
+ // Arrange / Act
85
+ renderChoice({
86
+ checked: true,
87
+ correct: true,
88
+ showCorrectness: true,
89
+ reviewMode: true,
90
+ });
91
+
92
+ // Assert
93
+ expect(screen.getByText("Correct (selected)")).toBeVisible();
94
+ });
95
+
96
+ it("shows incorrect selected when in review mode", () => {
97
+ // Arrange / Act
98
+ renderChoice({
99
+ checked: true,
100
+ correct: false,
101
+ showCorrectness: true,
102
+ reviewMode: true,
103
+ });
104
+
105
+ // Assert
106
+ expect(screen.getByText("Incorrect (selected)")).toBeVisible();
107
+ });
108
+ });
109
+
110
+ // Tests 1 of 2 element types used to select a choice
111
+ describe("choice button", () => {
112
+ it("selects the choice by clicking the button", () => {
113
+ // Arrange / Act
114
+ const onChangeSpy = jest.fn();
115
+ renderChoice({onChange: onChangeSpy});
116
+
117
+ const button = screen.getByRole("button", {hidden: true});
118
+ userEvent.click(button);
119
+
120
+ // Assert
121
+ expect(onChangeSpy).toHaveBeenCalledWith({
122
+ checked: true,
123
+ crossedOut: false,
124
+ });
125
+ });
126
+
127
+ it("has correct aria-disabled when disabled", () => {
128
+ // Arrange / Act
129
+ renderChoice({disabled: true});
130
+
131
+ const button = screen.getByRole("button", {hidden: true});
132
+
133
+ // Assert
134
+ expect(button.getAttribute("aria-disabled")).toBe("true");
135
+ });
136
+
137
+ it("has correct aria-disabled when not disabled", () => {
138
+ // Arrange / Act
139
+ renderChoice({disabled: false});
140
+
141
+ const button = screen.getByRole("button", {hidden: true});
142
+
143
+ // Assert
144
+ expect(button.getAttribute("aria-disabled")).toBe("false");
145
+ });
146
+
147
+ it("can be checked", () => {
148
+ // Arrange
149
+ let checked = false;
150
+ renderChoice({
151
+ checked,
152
+ onChange: (next) => {
153
+ checked = next.checked;
154
+ },
155
+ });
156
+
157
+ // Act
158
+ const button = screen.getByRole("button", {hidden: true});
159
+ userEvent.click(button);
160
+
161
+ // Assert
162
+ expect(checked).toBeTrue();
163
+ });
164
+
165
+ it("can be unchecked", () => {
166
+ // Arrange
167
+ let checked = true;
168
+ renderChoice({
169
+ checked,
170
+ onChange: (next) => {
171
+ checked = next.checked;
172
+ },
173
+ });
174
+
175
+ // Act
176
+ const button = screen.getByRole("button", {hidden: true});
177
+ userEvent.click(button);
178
+
179
+ // Assert
180
+ expect(checked).toBeFalse();
181
+ });
182
+ });
183
+
184
+ // Tests 2 of 2 element types used to select a choice
185
+ describe("choice input (screen reader only)", () => {
186
+ it("selects the choice by clicking the input", () => {
187
+ // Arrange / Act
188
+ const onChangeSpy = jest.fn();
189
+ renderChoice({onChange: onChangeSpy});
190
+
191
+ const input = screen.getByRole("radio", {
192
+ name: "(Choice A) This is a possible choice",
193
+ });
194
+ userEvent.click(input);
195
+
196
+ // Assert
197
+ expect(onChangeSpy).toHaveBeenCalledWith({
198
+ checked: true,
199
+ crossedOut: false,
200
+ });
201
+ });
202
+
203
+ it("registers as unchecked with checked set to false", () => {
204
+ // Arrange / Act
205
+ renderChoice({checked: false});
206
+
207
+ const input = screen.getByRole("radio", {
208
+ name: "(Choice A) This is a possible choice",
209
+ });
210
+
211
+ // Assert
212
+ expect(input).not.toBeChecked();
213
+ });
214
+
215
+ it("registers as checked with checked set to true", () => {
216
+ // Arrange / Act
217
+ renderChoice({checked: true});
218
+
219
+ const input = screen.getByRole("radio", {
220
+ name: "(Choice A, Checked) This is a possible choice",
221
+ });
222
+
223
+ // Assert
224
+ expect(input).toBeChecked();
225
+ });
226
+
227
+ it("has correct a11y text when crossed out", () => {
228
+ // Arrange / Act
229
+ renderChoice({crossedOut: true});
230
+
231
+ const input = screen.getByRole("radio", {
232
+ name: "(Choice A, Crossed out) This is a possible choice",
233
+ });
234
+
235
+ // Assert
236
+ expect(input).toBeVisible();
237
+ });
238
+
239
+ it("has correct a11y text when incorrect, crossed out, and showCorrectness is true", () => {
240
+ // Arrange / Act
241
+ renderChoice({crossedOut: true, correct: false, showCorrectness: true});
242
+
243
+ const input = screen.getByRole("radio", {
244
+ name: "(Choice A, Crossed out, Incorrect) This is a possible choice",
245
+ });
246
+
247
+ // Assert
248
+ expect(input).toBeVisible();
249
+ });
250
+
251
+ it("has correct a11y text when correct, crossed out, and showCorrectness is true", () => {
252
+ // Arrange / Act
253
+ renderChoice({crossedOut: true, correct: true, showCorrectness: true});
254
+
255
+ const input = screen.getByRole("radio", {
256
+ name: "(Choice A, Crossed out, Correct) This is a possible choice",
257
+ });
258
+
259
+ // Assert
260
+ expect(input).toBeVisible();
261
+ });
262
+
263
+ it("can be checked", () => {
264
+ // Arrange
265
+ let checked = false;
266
+ renderChoice({
267
+ checked,
268
+ onChange: (next) => {
269
+ checked = next.checked;
270
+ },
271
+ });
272
+
273
+ // Act
274
+ const input = screen.getByRole("radio", {
275
+ name: "(Choice A) This is a possible choice",
276
+ });
277
+ userEvent.click(input);
278
+
279
+ // Assert
280
+ expect(checked).toBe(true);
281
+ });
282
+
283
+ it("can be unchecked", () => {
284
+ // Arrange
285
+ let checked = true;
286
+ renderChoice({
287
+ checked,
288
+ onChange: (next) => {
289
+ checked = next.checked;
290
+ },
291
+ });
292
+
293
+ // Act
294
+ const input = screen.getByRole("radio", {
295
+ name: "(Choice A, Checked) This is a possible choice",
296
+ });
297
+ userEvent.click(input);
298
+
299
+ // Assert
300
+ expect(checked).toBeFalse();
301
+ });
302
+ });
@@ -0,0 +1,23 @@
1
+ // @flow
2
+
3
+ import {render, screen} from "@testing-library/react";
4
+ import * as React from "react";
5
+
6
+ import "@testing-library/jest-dom"; // Imports custom mathers
7
+
8
+ import FocusRing from "../../radio/focus-ring.jsx";
9
+
10
+ describe("choice icon", () => {
11
+ describe.each([[true], [false]])("multipleSelect: %s", (multipleSelect) => {
12
+ it("renders with the correct border radius", () => {
13
+ // Arrange/Act
14
+ render(<FocusRing multipleSelect={multipleSelect} />);
15
+
16
+ const focusRing = screen.getByTestId("focus-ring");
17
+
18
+ // Assert
19
+ const expectedRadius = multipleSelect ? "5px" : "50%";
20
+ expect(focusRing).toHaveStyle(`border-radius: ${expectedRadius}`);
21
+ });
22
+ });
23
+ });
@@ -0,0 +1,65 @@
1
+ // @flow
2
+
3
+ import {render, screen} from "@testing-library/react";
4
+ import * as React from "react";
5
+
6
+ import "@testing-library/jest-dom"; // Imports custom mathers
7
+
8
+ import OptionStatus from "../../radio/option-status.jsx";
9
+
10
+ function renderOptionStatus(options) {
11
+ const defaultOptions = {
12
+ correct: false,
13
+ checked: false,
14
+ crossedOut: false,
15
+ previouslyAnswered: false,
16
+ reviewMode: true,
17
+ };
18
+
19
+ const overwrittenOptions = {...defaultOptions, ...options};
20
+
21
+ return render(<OptionStatus {...overwrittenOptions} />);
22
+ }
23
+
24
+ describe("answer text", () => {
25
+ it.each([
26
+ [true, "Correct"],
27
+ [false, "Incorrect"],
28
+ ])(
29
+ "renders with the correct text based on 'correct' prop: %s => %s",
30
+ (correct, expectedText) => {
31
+ // Arrange/Act
32
+ renderOptionStatus({correct: correct});
33
+ const optionStatus = screen.getByText(expectedText);
34
+
35
+ // Assert
36
+ expect(optionStatus).toBeVisible();
37
+ },
38
+ );
39
+
40
+ it.each([
41
+ [true, "Incorrect (selected)"],
42
+ [false, "Incorrect"],
43
+ ])(
44
+ "renders with the correct text based on 'checked' prop: %s => %s",
45
+ (checked, expectedText) => {
46
+ // Arrange/Act
47
+ renderOptionStatus({correct: false, checked: checked});
48
+ const optionStatus = screen.getByText(expectedText);
49
+
50
+ // Assert
51
+ expect(optionStatus).toBeVisible();
52
+ },
53
+ );
54
+
55
+ it("renders correctly based on 'crossedOut' and 'correct'", () => {
56
+ // Arrange/Act
57
+ renderOptionStatus({crossedOut: true, correct: true});
58
+ const optionStatus = screen.getByText(
59
+ "Correct (but you crossed it out)",
60
+ );
61
+
62
+ // Assert
63
+ expect(optionStatus).toBeVisible();
64
+ });
65
+ });
@@ -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();