@khanacademy/math-input 16.1.2 → 16.3.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/dist/utils.d.ts CHANGED
@@ -3,3 +3,15 @@ export declare const DecimalSeparator: {
3
3
  readonly PERIOD: ".";
4
4
  };
5
5
  export declare const decimalSeparator: string;
6
+ /**
7
+ * convertDotToTimes (aka `times`) is an option the content creators have to
8
+ * use × (TIMES) rather than · (CDOT) for multiplication (for younger learners).
9
+ * Some locales _only_ use one or the other for all multiplication regardless
10
+ * of age.
11
+ *
12
+ * convertDotToTimesByLocale overrides convertDotToTimes for those locales.
13
+ *
14
+ * @param {boolean} convertDotToTimes - the setting set by content creators
15
+ * @returns {boolean} - true to convert to × (TIMES), false to use · (CDOT)
16
+ */
17
+ export declare function convertDotToTimesByLocale(convertDotToTimes: boolean): boolean;
package/package.json CHANGED
@@ -3,7 +3,7 @@
3
3
  "description": "Khan Academy's new expression editor for the mobile web.",
4
4
  "author": "Khan Academy",
5
5
  "license": "MIT",
6
- "version": "16.1.2",
6
+ "version": "16.3.0",
7
7
  "publishConfig": {
8
8
  "access": "public"
9
9
  },
@@ -99,7 +99,7 @@ describe("math input integration", () => {
99
99
  ).not.toBeInTheDocument();
100
100
  });
101
101
 
102
- it("shows the keypad after input interaction", async () => {
102
+ it("shows the keypad after input touch-interaction", async () => {
103
103
  render(<ConnectedMathInput />);
104
104
 
105
105
  const input = screen.getByLabelText(
@@ -115,6 +115,22 @@ describe("math input integration", () => {
115
115
  expect(screen.getByRole("button", {name: "1"})).toBeVisible();
116
116
  });
117
117
 
118
+ it("shows the keypad after input click-interaction", async () => {
119
+ render(<ConnectedMathInput />);
120
+
121
+ const input = screen.getByLabelText(
122
+ "Math input box Tap with one or two fingers to open keyboard",
123
+ );
124
+
125
+ userEvent.click(input);
126
+
127
+ await waitFor(() => {
128
+ expect(screen.getByRole("button", {name: "4"})).toBeVisible();
129
+ });
130
+
131
+ expect(screen.getByRole("button", {name: "1"})).toBeVisible();
132
+ });
133
+
118
134
  it("updates input when using keypad", async () => {
119
135
  render(<ConnectedMathInput />);
120
136
 
@@ -56,6 +56,8 @@ class MathInput extends React.Component<Props, State> {
56
56
  recordTouchStartOutside: (arg1: any) => void;
57
57
  // @ts-expect-error - TS2564 - Property 'blurOnTouchEndOutside' has no initializer and is not definitely assigned in the constructor.
58
58
  blurOnTouchEndOutside: (arg1: any) => void;
59
+ // @ts-expect-error - TS2564 - Property 'blurOnClickOutside' has no initializer and is not definitely assigned in the constructor.
60
+ blurOnClickOutside: (arg1: any) => void;
59
61
  dragListener: any;
60
62
  inputRef: HTMLDivElement | null | undefined;
61
63
  _isMounted: boolean | null | undefined;
@@ -65,7 +67,6 @@ class MathInput extends React.Component<Props, State> {
65
67
  _root: any;
66
68
  // @ts-expect-error - TS2564 - Property '_containerBounds' has no initializer and is not definitely assigned in the constructor.
67
69
  _containerBounds: ClientRect;
68
- _keypadBounds: ClientRect | null | undefined;
69
70
 
70
71
  static defaultProps: DefaultProps = {
71
72
  style: {},
@@ -120,6 +121,24 @@ class MathInput extends React.Component<Props, State> {
120
121
  this._root = this._container.querySelector(".mq-root-block");
121
122
  this._root.addEventListener("scroll", this._handleScroll);
122
123
 
124
+ const isWithinKeypadBounds = (x: number, y: number): boolean => {
125
+ const bounds = this._getKeypadBounds();
126
+
127
+ // If there are no bounds, then the keypad is not mounted, so we
128
+ // assume that the event is not within the keypad bounds.
129
+ if (!bounds) {
130
+ return false;
131
+ }
132
+
133
+ return (
134
+ (bounds.left <= x &&
135
+ bounds.right >= x &&
136
+ bounds.top <= y &&
137
+ bounds.bottom >= y) ||
138
+ bounds.bottom < y
139
+ );
140
+ };
141
+
123
142
  // Record the initial scroll displacement on touch start. This allows
124
143
  // us to detect whether a touch event was a scroll and only blur the
125
144
  // input on non-scrolls--blurring the input on scroll makes for a
@@ -139,19 +158,12 @@ class MathInput extends React.Component<Props, State> {
139
158
  this.props.keypadElement &&
140
159
  this.props.keypadElement.getDOMNode()
141
160
  ) {
142
- const bounds = this._getKeypadBounds();
143
161
  for (let i = 0; i < evt.changedTouches.length; i++) {
144
162
  const [x, y] = [
145
163
  evt.changedTouches[i].clientX,
146
164
  evt.changedTouches[i].clientY,
147
165
  ];
148
- if (
149
- (bounds.left <= x &&
150
- bounds.right >= x &&
151
- bounds.top <= y &&
152
- bounds.bottom >= y) ||
153
- bounds.bottom < y
154
- ) {
166
+ if (isWithinKeypadBounds(x, y)) {
155
167
  touchDidStartInOrBelowKeypad = true;
156
168
  break;
157
169
  }
@@ -194,28 +206,34 @@ class MathInput extends React.Component<Props, State> {
194
206
  }
195
207
  };
196
208
 
209
+ // We want to allow the user to blur the input by clicking outside of it
210
+ // when using ChromeOS third-party browsers that use mobile user agents,
211
+ // but don't actually simulate touch events.
212
+ this.blurOnClickOutside = (evt: any) => {
213
+ if (this.state.focused) {
214
+ if (!this._container.contains(evt.target)) {
215
+ if (
216
+ this.props.keypadElement &&
217
+ this.props.keypadElement.getDOMNode()
218
+ ) {
219
+ const [x, y] = [evt.clientX, evt.clientY];
220
+ // We only want to blur if the click is above the keypad,
221
+ // to the left of the keypad, or to the right of the keypad.
222
+ // The reasoning for not blurring for any clicks below the keypad is
223
+ // that the keypad may be anchored above the 'Check answer' bottom bar,
224
+ // in which case we don't want to dismiss the keypad on check.
225
+ if (!isWithinKeypadBounds(x, y)) {
226
+ this.blur();
227
+ }
228
+ }
229
+ }
230
+ }
231
+ };
232
+
197
233
  window.addEventListener("touchstart", this.recordTouchStartOutside);
198
234
  window.addEventListener("touchend", this.blurOnTouchEndOutside);
199
235
  window.addEventListener("touchcancel", this.blurOnTouchEndOutside);
200
-
201
- // HACK(benkomalo): if the window resizes, the keypad bounds can
202
- // change. That's a bit peeking into the internals of the keypad
203
- // itself, since we know bounds can change only when the viewport
204
- // changes, but seems like a rare enough thing to get wrong that it's
205
- // not worth wiring up extra things for the technical "purity" of
206
- // having the keypad notify of changes to us.
207
- window.addEventListener("resize", this._clearKeypadBoundsCache);
208
- window.addEventListener(
209
- "orientationchange",
210
- this._clearKeypadBoundsCache,
211
- );
212
- }
213
-
214
- // eslint-disable-next-line react/no-unsafe
215
- UNSAFE_componentWillReceiveProps(props: Props) {
216
- if (this.props.keypadElement !== props.keypadElement) {
217
- this._clearKeypadBoundsCache();
218
- }
236
+ window.addEventListener("click", this.blurOnClickOutside);
219
237
  }
220
238
 
221
239
  componentDidUpdate(prevProps: Props, prevState: State) {
@@ -234,23 +252,9 @@ class MathInput extends React.Component<Props, State> {
234
252
  window.removeEventListener("touchstart", this.recordTouchStartOutside);
235
253
  window.removeEventListener("touchend", this.blurOnTouchEndOutside);
236
254
  window.removeEventListener("touchcancel", this.blurOnTouchEndOutside);
237
- // @ts-expect-error - TS2769 - No overload matches this call.
238
- window.removeEventListener("resize", this._clearKeypadBoundsCache());
239
- window.removeEventListener(
240
- "orientationchange",
241
- // @ts-expect-error - TS2769 - No overload matches this call.
242
- this._clearKeypadBoundsCache(),
243
- );
255
+ window.removeEventListener("click", this.blurOnClickOutside);
244
256
  }
245
257
 
246
- _clearKeypadBoundsCache: () => void = () => {
247
- this._keypadBounds = null;
248
- };
249
-
250
- _cacheKeypadBounds: (arg1: any) => void = (keypadNode) => {
251
- this._keypadBounds = keypadNode.getBoundingClientRect();
252
- };
253
-
254
258
  _updateInputPadding: () => void = () => {
255
259
  this._container = ReactDOM.findDOMNode(this) as HTMLDivElement;
256
260
  this._root = this._container.querySelector(".mq-root-block");
@@ -263,14 +267,17 @@ class MathInput extends React.Component<Props, State> {
263
267
  this._root.style.fontSize = `${fontSizePt}pt`;
264
268
  };
265
269
 
266
- /** Gets and cache they bounds of the keypadElement */
267
- _getKeypadBounds: () => any = () => {
268
- if (!this._keypadBounds) {
269
- const node = this.props.keypadElement?.getDOMNode();
270
- this._cacheKeypadBounds(node);
270
+ /** Returns the current bounds of the keypadElement */
271
+ _getKeypadBounds(): DOMRect | null {
272
+ const keypadNode = this.props.keypadElement?.getDOMNode();
273
+
274
+ // If the keypad is mounted, return its bounds. Otherwise, return null.
275
+ if (keypadNode instanceof Element) {
276
+ return keypadNode.getBoundingClientRect();
271
277
  }
272
- return this._keypadBounds;
273
- };
278
+
279
+ return null;
280
+ }
274
281
 
275
282
  _updateCursorHandle: (arg1?: boolean) => void = (animateIntoPosition) => {
276
283
  const containerBounds = this._container.getBoundingClientRect();
@@ -624,6 +631,32 @@ class MathInput extends React.Component<Props, State> {
624
631
  }
625
632
  };
626
633
 
634
+ // We want to allow the user to be able to focus the input via click
635
+ // when using ChromeOS third-party browsers that use mobile user agents,
636
+ // but don't actually simulate touch events.
637
+ handleClick = (e: React.MouseEvent<HTMLDivElement>): void => {
638
+ e.stopPropagation();
639
+
640
+ // Hide the cursor handle on click
641
+ this._hideCursorHandle();
642
+
643
+ // Cache the container bounds, so as to avoid re-computing. If we don't
644
+ // have any content, then it's not necessary, since the cursor can't be
645
+ // moved anyway.
646
+ if (this.mathField.getContent() !== "") {
647
+ this._containerBounds = this._container.getBoundingClientRect();
648
+
649
+ // Make the cursor visible and set the handle-less cursor's
650
+ // location.
651
+ this._insertCursorAtClosestNode(e.clientX, e.clientY);
652
+ }
653
+
654
+ // Trigger a focus event, if we're not already focused.
655
+ if (!this.state.focused) {
656
+ this.focus();
657
+ }
658
+ };
659
+
627
660
  handleTouchMove: (arg1: React.TouchEvent<HTMLDivElement>) => void = (e) => {
628
661
  e.stopPropagation();
629
662
 
@@ -898,7 +931,7 @@ class MathInput extends React.Component<Props, State> {
898
931
  onTouchStart={this.handleTouchStart}
899
932
  onTouchMove={this.handleTouchMove}
900
933
  onTouchEnd={this.handleTouchEnd}
901
- onClick={(e) => e.stopPropagation()}
934
+ onClick={this.handleClick}
902
935
  role={"textbox"}
903
936
  ariaLabel={ariaLabel}
904
937
  >
@@ -1,3 +1,4 @@
1
+ import * as wbi18n from "@khanacademy/wonder-blocks-i18n";
1
2
  import {render, screen} from "@testing-library/react";
2
3
  import userEvent from "@testing-library/user-event";
3
4
  import * as React from "react";
@@ -155,6 +156,42 @@ describe("keypad", () => {
155
156
  expect(screen.getByTestId("TIMES")).toBeInTheDocument();
156
157
  });
157
158
 
159
+ it(`forces CDOT in locales that require it`, () => {
160
+ // Arrange
161
+ jest.spyOn(wbi18n, "getLocale").mockReturnValue("az");
162
+
163
+ // Act
164
+ render(
165
+ <Keypad
166
+ onClickKey={() => {}}
167
+ convertDotToTimes={true}
168
+ onAnalyticsEvent={async () => {}}
169
+ />,
170
+ );
171
+
172
+ // Assert
173
+ expect(screen.getByTestId("CDOT")).toBeInTheDocument();
174
+ expect(screen.queryByTestId("TIMES")).not.toBeInTheDocument();
175
+ });
176
+
177
+ it(`forces TIMES in locales that require it`, () => {
178
+ // Arrange
179
+ jest.spyOn(wbi18n, "getLocale").mockReturnValue("fr");
180
+
181
+ // Act
182
+ render(
183
+ <Keypad
184
+ onClickKey={() => {}}
185
+ convertDotToTimes={false}
186
+ onAnalyticsEvent={async () => {}}
187
+ />,
188
+ );
189
+
190
+ // Assert
191
+ expect(screen.getByTestId("TIMES")).toBeInTheDocument();
192
+ expect(screen.queryByTestId("CDOT")).not.toBeInTheDocument();
193
+ });
194
+
158
195
  it(`hides the tabs if providing the Fraction Keypad`, () => {
159
196
  // Arrange
160
197
  // Act
@@ -1,6 +1,7 @@
1
1
  import * as React from "react";
2
2
 
3
3
  import Keys from "../../data/key-configs";
4
+ import {convertDotToTimesByLocale} from "../../utils";
4
5
 
5
6
  import {KeypadButton} from "./keypad-button";
6
7
  import {getCursorContextConfig} from "./utils";
@@ -56,7 +57,11 @@ export default function SharedKeys(props: Props) {
56
57
 
57
58
  {/* Row 2 */}
58
59
  <KeypadButton
59
- keyConfig={convertDotToTimes ? Keys.TIMES : Keys.CDOT}
60
+ keyConfig={
61
+ convertDotToTimesByLocale(!!convertDotToTimes)
62
+ ? Keys.TIMES
63
+ : Keys.CDOT
64
+ }
60
65
  onClickKey={onClickKey}
61
66
  coord={[4, 1]}
62
67
  secondary
package/src/index.ts CHANGED
@@ -40,6 +40,7 @@ export {
40
40
  // External API of the "Provided" keypad component
41
41
  export {keypadElementPropType} from "./components/prop-types";
42
42
  export type {KeypadAPI, KeypadConfiguration} from "./types";
43
+ export {convertDotToTimesByLocale} from "./utils";
43
44
 
44
45
  // Key list, configuration map, and types
45
46
  export type {default as Keys} from "./data/keys";
@@ -0,0 +1,33 @@
1
+ import * as wbi18n from "@khanacademy/wonder-blocks-i18n";
2
+
3
+ import {convertDotToTimesByLocale} from "./utils";
4
+
5
+ describe("utils", () => {
6
+ describe("multiplicationSymbol", () => {
7
+ it("passes through convertDotToTimes in locales that don't override it", () => {
8
+ jest.spyOn(wbi18n, "getLocale").mockReturnValue("en");
9
+
10
+ const result1 = convertDotToTimesByLocale(true);
11
+ const result2 = convertDotToTimesByLocale(false);
12
+
13
+ expect(result1).toBe(true);
14
+ expect(result2).toBe(false);
15
+ });
16
+
17
+ it("overrides with false for locales that only use dot", () => {
18
+ jest.spyOn(wbi18n, "getLocale").mockReturnValue("az");
19
+
20
+ const result = convertDotToTimesByLocale(true);
21
+
22
+ expect(result).toBe(false);
23
+ });
24
+
25
+ it("overrides with true for locales that only use x", () => {
26
+ jest.spyOn(wbi18n, "getLocale").mockReturnValue("fr");
27
+
28
+ const result = convertDotToTimesByLocale(false);
29
+
30
+ expect(result).toBe(true);
31
+ });
32
+ });
33
+ });
package/src/utils.ts CHANGED
@@ -1,4 +1,4 @@
1
- import {getDecimalSeparator} from "@khanacademy/wonder-blocks-i18n";
1
+ import {getDecimalSeparator, getLocale} from "@khanacademy/wonder-blocks-i18n";
2
2
 
3
3
  export const DecimalSeparator = {
4
4
  COMMA: ",",
@@ -15,3 +15,47 @@ export const decimalSeparator: string =
15
15
  getDecimalSeparator() === ","
16
16
  ? DecimalSeparator.COMMA
17
17
  : DecimalSeparator.PERIOD;
18
+
19
+ const CDOT_ONLY = [
20
+ "az",
21
+ "cs",
22
+ "da",
23
+ "de",
24
+ "hu",
25
+ "hy",
26
+ "kk",
27
+ "ky",
28
+ "lt",
29
+ "lv",
30
+ "nb",
31
+ "sk",
32
+ "sr",
33
+ "sv",
34
+ "uz",
35
+ ];
36
+ const TIMES_ONLY = ["fr", "tr", "pt-pt"];
37
+
38
+ /**
39
+ * convertDotToTimes (aka `times`) is an option the content creators have to
40
+ * use × (TIMES) rather than · (CDOT) for multiplication (for younger learners).
41
+ * Some locales _only_ use one or the other for all multiplication regardless
42
+ * of age.
43
+ *
44
+ * convertDotToTimesByLocale overrides convertDotToTimes for those locales.
45
+ *
46
+ * @param {boolean} convertDotToTimes - the setting set by content creators
47
+ * @returns {boolean} - true to convert to × (TIMES), false to use · (CDOT)
48
+ */
49
+ export function convertDotToTimesByLocale(convertDotToTimes: boolean): boolean {
50
+ const locale = getLocale();
51
+
52
+ if (CDOT_ONLY.includes(locale)) {
53
+ return false;
54
+ }
55
+
56
+ if (TIMES_ONLY.includes(locale)) {
57
+ return true;
58
+ }
59
+
60
+ return convertDotToTimes;
61
+ }