@khanacademy/perseus 12.1.0 → 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.1.0",
6
+ "version": "12.2.0",
7
7
  "publishConfig": {
8
8
  "access": "public"
9
9
  },
@@ -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
+ });
@@ -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