@khanacademy/perseus 12.0.1 → 12.2.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.
package/package.json CHANGED
@@ -3,7 +3,7 @@
3
3
  "description": "Core Perseus API (includes renderers and widgets)",
4
4
  "author": "Khan Academy",
5
5
  "license": "MIT",
6
- "version": "12.0.1",
6
+ "version": "12.2.0",
7
7
  "publishConfig": {
8
8
  "access": "public"
9
9
  },
@@ -24,7 +24,7 @@
24
24
  "dependencies": {
25
25
  "@khanacademy/kas": "^0.3.3",
26
26
  "@khanacademy/kmath": "^0.1.4",
27
- "@khanacademy/math-input": "^14.1.1",
27
+ "@khanacademy/math-input": "^14.2.0",
28
28
  "@khanacademy/perseus-core": "1.1.2",
29
29
  "@khanacademy/perseus-linter": "^0.3.6",
30
30
  "@khanacademy/pure-markdown": "^0.2.8",
@@ -57,7 +57,7 @@
57
57
  "jquery": "^2.1.1",
58
58
  "katex": "^0.11.1",
59
59
  "lodash.debounce": "^4.0.8",
60
- "mathquill": "git+https://git@github.com/Khan/mathquill.git#32d9f351aaa68537170b3120a52e99b8def3a2c3",
60
+ "mathquill": "git+https://git@github.com/Khan/mathquill.git#48410e80d760bbd5105544d4a4ab459a28dc2cbc",
61
61
  "perseus-build-settings": "^0.2.1",
62
62
  "prop-types": "^15.6.1",
63
63
  "react": "16.14.0",
@@ -7,6 +7,7 @@ import {
7
7
  multiSectionArticle,
8
8
  passageArticle,
9
9
  articleWithExpression,
10
+ multiSectionArticleWithExpression,
10
11
  } from "../__testdata__/article-renderer.testdata";
11
12
  import ArticleRenderer from "../article-renderer";
12
13
  import TestKeypadContextWrapper from "../widgets/__stories__/test-keypad-context-wrapper";
@@ -55,7 +56,7 @@ export const PassageArticle = ({useNewStyles}): any => (
55
56
  export const ExpressionArticle = ({useNewStyles}): any => (
56
57
  <TestKeypadContextWrapper>
57
58
  <KeypadContext.Consumer>
58
- {({keypadElement, setRenderer, scrollableElement}) => (
59
+ {({keypadElement, setRenderer}) => (
59
60
  <ArticleRenderer
60
61
  ref={(node) => {
61
62
  setRenderer(node);
@@ -63,7 +64,32 @@ export const ExpressionArticle = ({useNewStyles}): any => (
63
64
  json={articleWithExpression}
64
65
  dependencies={storybookDependenciesV2}
65
66
  useNewStyles={useNewStyles}
66
- apiOptions={{isMobile: true, customKeypad: true}}
67
+ apiOptions={{
68
+ isMobile: true,
69
+ customKeypad: true,
70
+ }}
71
+ keypadElement={keypadElement}
72
+ />
73
+ )}
74
+ </KeypadContext.Consumer>
75
+ </TestKeypadContextWrapper>
76
+ );
77
+
78
+ export const MultiSectionedExpressionArticle = ({useNewStyles}): any => (
79
+ <TestKeypadContextWrapper>
80
+ <KeypadContext.Consumer>
81
+ {({keypadElement, setRenderer}) => (
82
+ <ArticleRenderer
83
+ ref={(node) => {
84
+ setRenderer(node);
85
+ }}
86
+ json={multiSectionArticleWithExpression}
87
+ dependencies={storybookDependenciesV2}
88
+ useNewStyles={useNewStyles}
89
+ apiOptions={{
90
+ isMobile: true,
91
+ customKeypad: true,
92
+ }}
67
93
  keypadElement={keypadElement}
68
94
  />
69
95
  )}
@@ -125,3 +125,88 @@ export const multiSectionArticle: ReadonlyArray<PerseusRenderer> = [
125
125
  widgets: {},
126
126
  },
127
127
  ];
128
+
129
+ export const multiSectionArticleWithExpression: ReadonlyArray<PerseusRenderer> =
130
+ [
131
+ {
132
+ content:
133
+ "### Practice Problem\n\n$8\\cdot(11i+2)=$ [[☃ expression 1]] \n*Your answer should be a complex number in the form $a+bi$ where $a$ and $b$ are real numbers.*",
134
+ images: {},
135
+ widgets: {
136
+ "expression 1": {
137
+ alignment: "default",
138
+ graded: true,
139
+ options: {
140
+ answerForms: [
141
+ {
142
+ considered: "correct",
143
+ form: true,
144
+ simplify: false,
145
+ value: "16+88i",
146
+ },
147
+ ],
148
+ buttonSets: ["basic"],
149
+ functions: ["f", "g", "h"],
150
+ times: false,
151
+ },
152
+ static: false,
153
+ type: "expression",
154
+ version: {major: 1, minor: 0},
155
+ },
156
+ },
157
+ },
158
+ {
159
+ content:
160
+ "### Practice Problem\n\n$8\\cdot(11i+2)=$ [[☃ expression 2]] \n*Your answer should be a complex number in the form $a+bi$ where $a$ and $b$ are real numbers.*",
161
+ images: {},
162
+ widgets: {
163
+ "expression 2": {
164
+ alignment: "default",
165
+ graded: true,
166
+ options: {
167
+ answerForms: [
168
+ {
169
+ considered: "correct",
170
+ form: true,
171
+ simplify: false,
172
+ value: "16+88i",
173
+ },
174
+ ],
175
+ buttonSets: ["basic"],
176
+ functions: ["f", "g", "h"],
177
+ times: false,
178
+ },
179
+ static: false,
180
+ type: "expression",
181
+ version: {major: 1, minor: 0},
182
+ },
183
+ },
184
+ },
185
+ {
186
+ content:
187
+ "### Practice Problem\n\n$8\\cdot(11i+2)=$ [[☃ expression 3]] \n*Your answer should be a complex number in the form $a+bi$ where $a$ and $b$ are real numbers.*",
188
+ images: {},
189
+ widgets: {
190
+ "expression 3": {
191
+ alignment: "default",
192
+ graded: true,
193
+ options: {
194
+ answerForms: [
195
+ {
196
+ considered: "correct",
197
+ form: true,
198
+ simplify: false,
199
+ value: "16+88i",
200
+ },
201
+ ],
202
+ buttonSets: ["basic"],
203
+ functions: ["f", "g", "h"],
204
+ times: false,
205
+ },
206
+ static: false,
207
+ type: "expression",
208
+ version: {major: 1, minor: 0},
209
+ },
210
+ },
211
+ },
212
+ ];
@@ -0,0 +1,131 @@
1
+ import {
2
+ StatefulKeypadContextProvider,
3
+ KeypadContext,
4
+ } from "@khanacademy/math-input";
5
+ import {RenderStateRoot} from "@khanacademy/wonder-blocks-core";
6
+ import {screen, render, fireEvent, waitFor} from "@testing-library/react";
7
+ import * as React from "react";
8
+ import "@testing-library/jest-dom"; // Imports custom matchers
9
+
10
+ import {
11
+ testDependencies,
12
+ testDependenciesV2,
13
+ } from "../../../../testing/test-dependencies";
14
+ import KeypadSwitch from "../../../math-input/src/components/keypad-switch";
15
+ import {articleWithExpression} from "../__testdata__/article-renderer.testdata";
16
+ import ArticleRenderer from "../article-renderer";
17
+ import * as Dependencies from "../dependencies";
18
+ import {ApiOptions} from "../perseus-api";
19
+
20
+ import type {APIOptions} from "../types";
21
+
22
+ function KeypadWithContext() {
23
+ return (
24
+ <KeypadContext.Consumer>
25
+ {({setKeypadElement}) => {
26
+ return (
27
+ <KeypadSwitch
28
+ onElementMounted={setKeypadElement}
29
+ onDismiss={() => {}}
30
+ onAnalyticsEvent={async () => {}}
31
+ useV2Keypad
32
+ />
33
+ );
34
+ }}
35
+ </KeypadContext.Consumer>
36
+ );
37
+ }
38
+ // This looks alot like `widgets/__tests__/renderQuestion.jsx', except we use
39
+ // the ArticleRenderer instead of Renderer
40
+ export const RenderArticle = (
41
+ apiOptions: APIOptions = Object.freeze({}),
42
+ ): {
43
+ container: HTMLElement;
44
+ renderer: ArticleRenderer;
45
+ } => {
46
+ let renderer: ArticleRenderer | null = null;
47
+ const {container} = render(
48
+ <RenderStateRoot>
49
+ <StatefulKeypadContextProvider>
50
+ <KeypadContext.Consumer>
51
+ {({keypadElement, setRenderer}) => (
52
+ <ArticleRenderer
53
+ ref={(node) => {
54
+ renderer = node;
55
+ setRenderer(node);
56
+ }}
57
+ json={articleWithExpression}
58
+ dependencies={testDependenciesV2}
59
+ apiOptions={{...apiOptions}}
60
+ keypadElement={keypadElement}
61
+ />
62
+ )}
63
+ </KeypadContext.Consumer>
64
+ <KeypadWithContext />
65
+ </StatefulKeypadContextProvider>
66
+ </RenderStateRoot>,
67
+ );
68
+ if (!renderer) {
69
+ throw new Error(`Failed to render!`);
70
+ }
71
+ return {container, renderer};
72
+ };
73
+
74
+ describe("article renderer", () => {
75
+ beforeEach(() => {
76
+ // Mock ResizeObserver used by the mobile keypad
77
+ window.ResizeObserver = jest.fn().mockImplementation(() => ({
78
+ observe: jest.fn(),
79
+ unobserve: jest.fn(),
80
+ disconnect: jest.fn(),
81
+ }));
82
+
83
+ jest.spyOn(Dependencies, "getDependencies").mockReturnValue(
84
+ testDependencies,
85
+ );
86
+ });
87
+
88
+ afterEach(() => {
89
+ // The Renderer uses a timer to wait for widgets to complete rendering.
90
+ // If we don't spin the timers here, then the timer fires in the test
91
+ // _after_ and breaks it because we do setState() in the callback,
92
+ // and by that point the component has been unmounted.
93
+ jest.runOnlyPendingTimers();
94
+ });
95
+
96
+ it("should render the content", () => {
97
+ // Arrange and Act
98
+ RenderArticle({
99
+ ...ApiOptions.defaults,
100
+ isMobile: false,
101
+ customKeypad: false,
102
+ });
103
+
104
+ // Assert
105
+ expect(screen.getByRole("textbox")).toBeInTheDocument();
106
+ });
107
+
108
+ it("should call the onFocusChanged callback when an input is focused", async () => {
109
+ // Arrange
110
+ const answerableCallback = jest.fn();
111
+
112
+ // Act
113
+ RenderArticle({
114
+ ...ApiOptions.defaults,
115
+ onFocusChange: answerableCallback,
116
+ isMobile: true,
117
+ customKeypad: true,
118
+ });
119
+
120
+ const input = screen.getByLabelText(
121
+ "Math input box Tap with one or two fingers to open keyboard",
122
+ );
123
+
124
+ fireEvent.touchStart(input);
125
+
126
+ await waitFor(() => {
127
+ expect(screen.getByRole("button", {name: "4"})).toBeVisible();
128
+ });
129
+ expect(answerableCallback).toHaveBeenCalled();
130
+ });
131
+ });
@@ -1,6 +1,6 @@
1
1
  import {StatefulKeypadContextProvider} from "@khanacademy/math-input";
2
2
  import {RenderStateRoot} from "@khanacademy/wonder-blocks-core";
3
- import {fireEvent, render, screen, waitFor} from "@testing-library/react";
3
+ import {fireEvent, render, screen} from "@testing-library/react";
4
4
  import userEvent from "@testing-library/user-event";
5
5
  import * as React from "react";
6
6
  import "@testing-library/jest-dom"; // Imports custom matchers
@@ -88,7 +88,6 @@ export const renderQuestion = (
88
88
  area. Without both of these, it fails to render anything! */}
89
89
  <div id="workarea" />
90
90
  <div id="hintsarea" />
91
-
92
91
  <Peripherals />
93
92
  </StatefulKeypadContextProvider>
94
93
  </RenderStateRoot>,
@@ -312,23 +311,6 @@ describe("item renderer", () => {
312
311
  );
313
312
  });
314
313
 
315
- it("should activate the keypad when widget with input is focused", async () => {
316
- // Arrange
317
- const {renderer} = renderQuestion(itemWithInput, {
318
- isMobile: true,
319
- customKeypad: true,
320
- useV2Keypad: true,
321
- });
322
-
323
- // Act
324
- renderer.focus();
325
-
326
- // Assert
327
- await waitFor(() => {
328
- expect(screen.getByLabelText("7")).toBeVisible();
329
- });
330
- });
331
-
332
314
  it("should provide current and previous focus paths on focus change to, and away from, a single widget", () => {
333
315
  // Arrange
334
316
  const onFocusChange = jest.fn();
@@ -44,6 +44,7 @@ class ArticleRenderer
44
44
  implements KeypadContextRendererInterface
45
45
  {
46
46
  _currentFocus: any;
47
+ sectionRenderers: Array<Renderer> = [];
47
48
 
48
49
  static defaultProps: DefaultProps = {
49
50
  apiOptions: ApiOptions.defaults,
@@ -87,14 +88,21 @@ class ArticleRenderer
87
88
  // paths, so as to check whether the focused path represents an
88
89
  // input.
89
90
  let didFocusInput = false;
91
+ let focusedInput;
92
+
90
93
  if (this._currentFocus) {
91
- const [sectionRef, ...focusPath] = this._currentFocus;
92
- // eslint-disable-next-line react/no-string-refs
93
- // @ts-expect-error - TS2339 - Property 'getInputPaths' does not exist on type 'ReactInstance'.
94
- const inputPaths = this.refs[sectionRef].getInputPaths();
94
+ const [sectionIndex, ...focusPath] = this._currentFocus;
95
+
96
+ const inputPaths =
97
+ this.sectionRenderers[sectionIndex].getInputPaths();
98
+
95
99
  didFocusInput = inputPaths.some((inputPath) => {
96
100
  return Util.inputPathsEqual(inputPath, focusPath);
97
101
  });
102
+ focusedInput =
103
+ this.sectionRenderers[sectionIndex].getDOMNodeForPath(
104
+ focusPath,
105
+ );
98
106
  }
99
107
 
100
108
  if (this.props.apiOptions.onFocusChange != null) {
@@ -102,6 +110,7 @@ class ArticleRenderer
102
110
  this._currentFocus,
103
111
  prevFocusPath,
104
112
  didFocusInput ? keypadElement?.getDOMNode() : null,
113
+ didFocusInput ? focusedInput : null,
105
114
  );
106
115
  }
107
116
 
@@ -137,10 +146,8 @@ class ArticleRenderer
137
146
 
138
147
  blur: () => void = () => {
139
148
  if (this._currentFocus) {
140
- const [sectionRef, ...inputPath] = this._currentFocus;
141
- // eslint-disable-next-line react/no-string-refs
142
- // @ts-expect-error - TS2339 - Property 'blurPath' does not exist on type 'ReactInstance'.
143
- this.refs[sectionRef].blurPath(inputPath);
149
+ const [sectionIndex, ...inputPath] = this._currentFocus;
150
+ this.sectionRenderers[sectionIndex].blurPath(inputPath);
144
151
  }
145
152
  };
146
153
 
@@ -166,47 +173,53 @@ class ArticleRenderer
166
173
  });
167
174
 
168
175
  // TODO(alex): Add mobile api functions and pass them down here
169
- const sections = this._sections().map((section, i) => {
170
- const refForSection = `section-${i}`;
176
+ // We're using the index as the key here because we don't have a unique
177
+ // identifier for each section. This should be fine as we never remove
178
+ // or reorder sections.
179
+ const sections = this._sections().map((section, sectionIndex) => {
171
180
  return (
172
- <DependenciesContext.Provider value={this.props.dependencies}>
173
- <div key={i} className="clearfix">
174
- <Renderer
175
- {...section}
176
- ref={refForSection}
177
- key={i}
178
- key_={i}
179
- keypadElement={this.props.keypadElement}
180
- apiOptions={{
181
- ...apiOptions,
182
- onFocusChange: (newFocusPath, oldFocusPath) => {
183
- // Prefix the paths with the relevant section,
184
- // so as to allow us to distinguish between
185
- // equivalently-named inputs across Renderers.
186
- this._handleFocusChange(
187
- newFocusPath &&
188
- [refForSection].concat(
189
- newFocusPath,
190
- ),
191
- oldFocusPath &&
192
- [refForSection].concat(
193
- oldFocusPath,
194
- ),
195
- );
196
- },
197
- }}
198
- linterContext={PerseusLinter.pushContextStack(
199
- this.props.linterContext,
200
- "article",
201
- )}
202
- legacyPerseusLint={this.props.legacyPerseusLint}
203
- />
204
- </div>
205
- </DependenciesContext.Provider>
181
+ <div key={sectionIndex} className="clearfix">
182
+ <Renderer
183
+ {...section}
184
+ ref={(elem) => {
185
+ if (elem) {
186
+ this.sectionRenderers[sectionIndex] = elem;
187
+ }
188
+ }}
189
+ key={sectionIndex}
190
+ key_={sectionIndex}
191
+ keypadElement={this.props.keypadElement}
192
+ apiOptions={{
193
+ ...apiOptions,
194
+ onFocusChange: (newFocusPath, oldFocusPath) => {
195
+ // Prefix the paths with the relevant section index,
196
+ // so as to allow us to distinguish between
197
+ // equivalently-named inputs across Renderers.
198
+ this._handleFocusChange(
199
+ newFocusPath &&
200
+ [sectionIndex].concat(newFocusPath),
201
+ oldFocusPath &&
202
+ [sectionIndex].concat(oldFocusPath),
203
+ );
204
+ },
205
+ }}
206
+ linterContext={PerseusLinter.pushContextStack(
207
+ this.props.linterContext,
208
+ "article",
209
+ )}
210
+ legacyPerseusLint={this.props.legacyPerseusLint}
211
+ />
212
+ </div>
206
213
  );
207
214
  });
208
215
 
209
- return <div className={classes}>{sections}</div>;
216
+ return (
217
+ <div className={classes}>
218
+ <DependenciesContext.Provider value={this.props.dependencies}>
219
+ {sections}
220
+ </DependenciesContext.Provider>
221
+ </div>
222
+ );
210
223
  }
211
224
  }
212
225
 
@@ -441,6 +441,7 @@
441
441
  /* Widget CSS */
442
442
 
443
443
  .perseus-graph-padding {
444
+ box-sizing: content-box;
444
445
  padding: 25px 25px 0 0;
445
446
  }
446
447
 
@@ -1,5 +1,6 @@
1
1
  .perseus-image-widget {
2
- margin: 0;
2
+ margin-left: auto;
3
+ margin-right: auto;
3
4
 
4
5
  .perseus-image-title {
5
6
  text-align: center;
@@ -10,11 +11,6 @@
10
11
  padding-left: 16px;
11
12
  position: relative;
12
13
 
13
- // This offsets the paragraph's padding
14
- // So that the caption sits much closer
15
- // to the image
16
- top: -12px;
17
-
18
14
  &::before {
19
15
  content: "";
20
16
  position: absolute;
@@ -72,6 +72,7 @@ export const Question2 = (args: StoryArgs): React.ReactElement => {
72
72
  imageOptions != null
73
73
  ? {
74
74
  ...imageOptions,
75
+ alignment: "full-width",
75
76
  title: args.title,
76
77
  caption:
77
78
  "There is neither happiness nor unhappiness in this world; there is only the comparison of one state with another. Only a man who has felt ultimate despair is capable of feeling ultimate bliss. It is necessary to have wished for death in order to know how good it is to live.....the sum of all human wisdom will be contained in these two words: Wait and Hope",
@@ -1146,6 +1146,7 @@ exports[`group widget should snapshot: initial render 1`] = `
1146
1146
  >
1147
1147
  <figure
1148
1148
  class="perseus-image-widget"
1149
+ style="max-width: 380px;"
1149
1150
  >
1150
1151
  <div
1151
1152
  class="fixed-to-responsive svg-image"
@@ -26,6 +26,7 @@ exports[`image widget - isMobile %b should snapshot: first render 1`] = `
26
26
  >
27
27
  <figure
28
28
  class="perseus-image-widget"
29
+ style="max-width: 420px;"
29
30
  >
30
31
  <div
31
32
  class="fixed-to-responsive svg-image"
@@ -207,6 +208,7 @@ exports[`image widget - isMobile %b should snapshot: first render 2`] = `
207
208
  >
208
209
  <figure
209
210
  class="perseus-image-widget"
211
+ style="max-width: 420px;"
210
212
  >
211
213
  <div
212
214
  class="perseus-image-title"
@@ -220,7 +220,12 @@ class ImageWidget extends React.Component<Props> {
220
220
  }
221
221
 
222
222
  return (
223
- <figure className="perseus-image-widget">
223
+ <figure
224
+ className="perseus-image-widget"
225
+ style={{
226
+ maxWidth: backgroundImage.width,
227
+ }}
228
+ >
224
229
  {image}
225
230
  {alt}
226
231
  {titleAndCaption}
@@ -260,7 +265,12 @@ class ImageWidget extends React.Component<Props> {
260
265
  }
261
266
 
262
267
  return (
263
- <figure className="perseus-image-widget">
268
+ <figure
269
+ className="perseus-image-widget"
270
+ style={{
271
+ maxWidth: backgroundImage.width,
272
+ }}
273
+ >
264
274
  {title}
265
275
  {image}
266
276
  {alt}