@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/CHANGELOG.md +14 -0
- package/dist/components/input/math-input.d.ts +4 -6
- package/dist/es/index.js +105 -48
- package/dist/es/index.js.map +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.js +99 -39
- package/dist/index.js.map +1 -1
- package/dist/utils.d.ts +12 -0
- package/package.json +1 -1
- package/src/components/__tests__/integration.test.tsx +17 -1
- package/src/components/input/math-input.tsx +84 -51
- package/src/components/keypad/__tests__/keypad.test.tsx +37 -0
- package/src/components/keypad/shared-keys.tsx +6 -1
- package/src/index.ts +1 -0
- package/src/utils.test.ts +33 -0
- package/src/utils.ts +45 -1
- package/tsconfig-build.tsbuildinfo +1 -1
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
|
@@ -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
|
-
|
|
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
|
-
/**
|
|
267
|
-
_getKeypadBounds
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
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
|
-
|
|
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={
|
|
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={
|
|
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
|
+
}
|