@khanacademy/perseus 11.6.0 → 12.0.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 (36) hide show
  1. package/CHANGELOG.md +35 -0
  2. package/dist/components/math-input.d.ts +38 -11
  3. package/dist/components/tex-buttons.d.ts +2 -1
  4. package/dist/es/index.css +5 -6
  5. package/dist/es/index.js +1454 -1371
  6. package/dist/es/index.js.map +1 -1
  7. package/dist/index.css +5 -6
  8. package/dist/index.d.ts +2 -8
  9. package/dist/index.js +1475 -1390
  10. package/dist/index.js.map +1 -1
  11. package/dist/item-renderer.d.ts +3 -0
  12. package/dist/perseus-types.d.ts +2 -1
  13. package/dist/perseus-version.d.ts +8 -0
  14. package/dist/strings.js +3 -0
  15. package/dist/version.d.ts +1 -11
  16. package/dist/widgets/expression.d.ts +15 -14
  17. package/package.json +9 -9
  18. package/src/__tests__/mock-asset-loading-widget.tsx +1 -2
  19. package/src/components/__stories__/graphie.stories.tsx +2 -2
  20. package/src/components/__stories__/math-input.stories.tsx +17 -4
  21. package/src/components/__tests__/math-input.test.tsx +95 -8
  22. package/src/components/math-input.tsx +274 -96
  23. package/src/components/tex-buttons.tsx +2 -2
  24. package/src/index.ts +2 -5
  25. package/src/item-renderer.tsx +3 -0
  26. package/src/item-version.ts +2 -2
  27. package/src/perseus-types.ts +11 -1
  28. package/src/perseus-version.ts +9 -0
  29. package/src/styles/perseus-renderer.less +5 -6
  30. package/src/styles/widgets/expression.less +0 -6
  31. package/src/version.ts +9 -10
  32. package/src/widgets/__stories__/expression.stories.tsx +24 -13
  33. package/src/widgets/__tests__/expression.test.tsx +4 -77
  34. package/src/widgets/expression.tsx +99 -94
  35. package/tsconfig-build.json +1 -0
  36. package/tsconfig-build.tsbuildinfo +1 -1
@@ -1,7 +1,7 @@
1
1
  import {KeypadContext, KeypadType} from "@khanacademy/math-input";
2
2
  import * as React from "react";
3
3
 
4
- import {ItemRendererWithDebugUI} from "../../../../../testing/item-renderer-with-debug-ui";
4
+ import {ServerItemRendererWithDebugUI} from "../../../../../testing/server-item-renderer-with-debug-ui";
5
5
  import {
6
6
  expressionItem2,
7
7
  expressionItem3,
@@ -10,7 +10,7 @@ import expressionExport from "../expression";
10
10
 
11
11
  import TestKeypadContextWrapper from "./test-keypad-context-wrapper";
12
12
 
13
- import type {PerseusItem} from "../../perseus-types";
13
+ import type {LegacyButtonSets, PerseusItem} from "../../perseus-types";
14
14
  import type {Keys as Key} from "@khanacademy/math-input";
15
15
 
16
16
  type StoryArgs = {
@@ -25,20 +25,27 @@ type Story = {
25
25
  type WrappedKeypadContextProps = {
26
26
  item: PerseusItem;
27
27
  customKeypad: boolean;
28
+ isMobile?: boolean;
28
29
  };
29
30
 
30
- const WrappedKeypadContext = (props: WrappedKeypadContextProps) => {
31
+ const WrappedKeypadContext = ({
32
+ item,
33
+ customKeypad,
34
+ isMobile = false,
35
+ }: WrappedKeypadContextProps) => {
31
36
  return (
32
37
  <TestKeypadContextWrapper>
33
38
  <KeypadContext.Consumer>
34
- {({keypadElement, setRenderer, scrollableElement}) => {
39
+ {({keypadElement}) => {
35
40
  return (
36
- <ItemRendererWithDebugUI
37
- item={props.item}
41
+ <ServerItemRendererWithDebugUI
42
+ item={item}
43
+ keypadElement={keypadElement}
38
44
  // Hardcoding the V2 Keypad to true as the Storybook Args
39
45
  // were not working.
40
46
  apiOptions={{
41
- customKeypad: props.customKeypad,
47
+ isMobile: isMobile,
48
+ customKeypad: customKeypad,
42
49
  useV2Keypad: true,
43
50
  }}
44
51
  />
@@ -62,12 +69,12 @@ export const DesktopKitchenSink = (args: StoryArgs): React.ReactElement => {
62
69
  "logarithms",
63
70
  "basic relations",
64
71
  "advanced relations",
65
- ],
72
+ ] as LegacyButtonSets,
66
73
  };
67
74
 
68
75
  const keypadConfiguration = {
69
76
  keypadType: KeypadType.EXPRESSION,
70
- extraKeys: ["x", "y", "z"] as Key[],
77
+ extraKeys: ["x", "y", "z"] as Array<Key>,
71
78
  };
72
79
 
73
80
  return (
@@ -98,13 +105,17 @@ export const Mobile = (args: StoryArgs): React.ReactElement => {
98
105
  return (
99
106
  <div>
100
107
  <p>
101
- For some reason you need to be{" "}
108
+ MathInput uses touch events;{" "}
102
109
  <a href="https://developer.chrome.com/docs/devtools/device-mode/">
103
- emulating mobile
110
+ emulate mobile
104
111
  </a>{" "}
105
- to see the custom keypad.
112
+ to use the custom keypad.
106
113
  </p>
107
- <WrappedKeypadContext item={expressionItem3} customKeypad={true} />
114
+ <WrappedKeypadContext
115
+ item={expressionItem3}
116
+ customKeypad
117
+ isMobile
118
+ />
108
119
  </div>
109
120
  );
110
121
  };
@@ -1,5 +1,5 @@
1
1
  import {it, describe, beforeEach} from "@jest/globals";
2
- import {screen, fireEvent} from "@testing-library/react";
2
+ import {screen} from "@testing-library/react";
3
3
  import userEvent from "@testing-library/user-event";
4
4
  import "@testing-library/jest-dom";
5
5
 
@@ -428,93 +428,20 @@ describe("error tooltip", () => {
428
428
  );
429
429
  });
430
430
 
431
- it("shows on error", () => {
431
+ it("shows error text in tooltip", async () => {
432
432
  // Arrange
433
433
  const {renderer} = renderQuestion(expressionItem2.question);
434
434
  const expression = renderer.findWidgets("expression 1")[0];
435
435
 
436
436
  // Act
437
437
  expression.insert("x&&&&&^1");
438
+ screen.getByRole("textbox").blur();
438
439
  renderer.guessAndScore();
439
- jest.runOnlyPendingTimers();
440
440
 
441
441
  // Assert
442
- const errorMessage = screen.getByText(
443
- "Sorry, I don't understand that!",
444
- );
445
- expect(errorMessage).not.toBeUndefined();
446
- });
447
-
448
- it("shows error text on mouse over", async () => {
449
- // Arrange
450
- const {renderer} = renderQuestion(expressionItem2.question);
451
- const expression = renderer.findWidgets("expression 1")[0];
452
-
453
- // Act
454
- expression.insert("x&&&&&^1");
455
- renderer.guessAndScore();
456
- jest.runOnlyPendingTimers();
457
-
458
- const tooltip = await screen.findByTestId("test-error-icon");
459
-
460
- // eslint-disable-next-line testing-library/prefer-user-event
461
- fireEvent.mouseEnter(tooltip);
462
-
463
- // Assert
464
- expect(
465
- screen.getByText("Sorry, I don't understand that!"),
466
- ).toBeVisible();
467
- });
468
- it("hides error text on mouse leave", async () => {
469
- // Arrange
470
- const {renderer} = renderQuestion(expressionItem2.question);
471
- const expression = renderer.findWidgets("expression 1")[0];
472
-
473
- expression.insert("x&&&&&^1");
474
- renderer.guessAndScore();
475
- jest.runOnlyPendingTimers();
476
-
477
- const tooltip = await screen.findByTestId("test-error-icon");
478
-
479
- // Act
480
- // NOTE(Nicole): The existing perseus code requires explicit events
481
- // eslint-disable-next-line testing-library/prefer-user-event
482
- fireEvent.mouseEnter(tooltip);
483
- // NOTE(Nicole): The existing perseus code requires explicit events
484
- // eslint-disable-next-line testing-library/prefer-user-event
485
- fireEvent.mouseLeave(tooltip);
486
-
487
- // Assert
488
- expect(
489
- screen.getByText("Sorry, I don't understand that!"),
490
- ).not.toBeVisible();
491
- });
492
-
493
- it("toggles error text on click", async () => {
494
- // Arrange
495
- const {renderer} = renderQuestion(expressionItem2.question);
496
- const expression = renderer.findWidgets("expression 1")[0];
497
-
498
- expression.insert("x&&&&&^1");
499
- renderer.guessAndScore();
500
- jest.runOnlyPendingTimers();
501
-
502
- const tooltip = await screen.findByTestId("test-error-icon");
503
-
504
- // Act & Assert
505
- // NOTE(Nicole): The existing perseus code requires explicit events
506
- // eslint-disable-next-line testing-library/prefer-user-event
507
- fireEvent.click(tooltip);
442
+ expect(screen.getByText("Oops!")).toBeVisible();
508
443
  expect(
509
444
  screen.getByText("Sorry, I don't understand that!"),
510
445
  ).toBeVisible();
511
-
512
- // Act & Assert
513
- // NOTE(Nicole): The existing perseus code requires explicit events
514
- // eslint-disable-next-line testing-library/prefer-user-event
515
- fireEvent.click(tooltip);
516
- expect(
517
- screen.getByText("Sorry, I don't understand that!"),
518
- ).not.toBeVisible();
519
446
  });
520
447
  });
@@ -1,24 +1,22 @@
1
1
  import * as KAS from "@khanacademy/kas";
2
- import {KeypadInput, KeypadType} from "@khanacademy/math-input";
2
+ import {KeyArray, KeypadInput, KeypadType} from "@khanacademy/math-input";
3
3
  import {linterContextDefault} from "@khanacademy/perseus-linter";
4
+ import {View} from "@khanacademy/wonder-blocks-core";
4
5
  import * as i18n from "@khanacademy/wonder-blocks-i18n";
6
+ import Tooltip from "@khanacademy/wonder-blocks-tooltip";
5
7
  import classNames from "classnames";
6
8
  import * as React from "react";
7
9
  import _ from "underscore";
8
10
 
9
- import InlineIcon from "../components/inline-icon";
10
11
  import MathInput from "../components/math-input";
11
- import Tooltip, {
12
- HorizontalDirection,
13
- VerticalDirection,
14
- } from "../components/tooltip";
15
12
  import {useDependencies} from "../dependencies";
16
- import {iconExclamationSign} from "../icon-paths";
17
13
  import {Errors as PerseusErrors, Log} from "../logging/log";
18
14
  import * as Changeable from "../mixins/changeable";
19
15
  import {ApiOptions, ClassNames as ApiClassNames} from "../perseus-api";
16
+ import a11y from "../util/a11y";
20
17
  import KhanAnswerTypes from "../util/answer-types";
21
18
 
19
+ import type {DependenciesContext} from "../dependencies";
22
20
  import type {
23
21
  PerseusExpressionWidgetOptions,
24
22
  PerseusExpressionAnswerForm,
@@ -34,6 +32,7 @@ import type {PropsFor} from "@khanacademy/wonder-blocks-core";
34
32
 
35
33
  type InputPath = ReadonlyArray<string>;
36
34
 
35
+ const ERROR_TITLE = i18n._("Oops!");
37
36
  const ERROR_MESSAGE = i18n._("Sorry, I don't understand that!");
38
37
 
39
38
  const insertBraces = (value) => {
@@ -85,7 +84,7 @@ type RenderProps = {
85
84
  type ExternalProps = WidgetProps<RenderProps, Rubric>;
86
85
 
87
86
  export type Props = ExternalProps &
88
- ReturnType<typeof useDependencies> & {
87
+ Partial<React.ContextType<typeof DependenciesContext>> & {
89
88
  apiOptions: NonNullable<ExternalProps["apiOptions"]>;
90
89
  buttonSets: NonNullable<ExternalProps["buttonSets"]>;
91
90
  functions: NonNullable<ExternalProps["functions"]>;
@@ -97,8 +96,9 @@ export type Props = ExternalProps &
97
96
  };
98
97
 
99
98
  export type ExpressionState = {
99
+ invalid: boolean;
100
100
  showErrorTooltip: boolean;
101
- showErrorText: boolean;
101
+ showErrorStyle: boolean;
102
102
  };
103
103
 
104
104
  type DefaultProps = {
@@ -121,7 +121,6 @@ type OnInputErrorFunctionType = (
121
121
  // The new, MathQuill input expression widget
122
122
  export class Expression extends React.Component<Props, ExpressionState> {
123
123
  _isMounted = false;
124
- errorTimeout: null | number = null;
125
124
 
126
125
  //#region Previously a class extension
127
126
  /* Content creators input a list of answers which are matched from top to
@@ -315,11 +314,14 @@ export class Expression extends React.Component<Props, ExpressionState> {
315
314
  displayName = "Expression";
316
315
 
317
316
  state: ExpressionState = {
317
+ invalid: false,
318
318
  showErrorTooltip: false,
319
- showErrorText: false,
319
+ showErrorStyle: false,
320
320
  };
321
321
 
322
322
  componentDidMount: () => void = () => {
323
+ document.addEventListener("mousedown", this._handleMouseDown);
324
+
323
325
  // TODO(scottgrant): This is a hack to remove the deprecated call to
324
326
  // this.isMounted() but is still considered an anti-pattern.
325
327
  this._isMounted = true;
@@ -334,42 +336,38 @@ export class Expression extends React.Component<Props, ExpressionState> {
334
336
  !_.isEqual(this.props.value, prevProps.value) ||
335
337
  !_.isEqual(this.props.functions, prevProps.functions)
336
338
  ) {
337
- // TODO(jeff, CP-3128): Use Wonder Blocks Timing API.
338
- // eslint-disable-next-line no-restricted-syntax
339
- // @ts-expect-error - TS2769 - No overload matches this call.
340
- clearTimeout(this.errorTimeout);
341
-
342
- if (this.parse(this.props.value, this.props).parsed) {
343
- // eslint-disable-next-line react/no-did-update-set-state
344
- this.setState({showErrorTooltip: false});
345
- } else {
346
- // Store timeout ID so that we can clear it above
347
- // TODO(jeff, CP-3128): Use Wonder Blocks Timing API.
348
- // eslint-disable-next-line no-restricted-syntax
349
- // @ts-expect-error - TS2322 - Type 'Timeout' is not assignable to type 'number'.
350
- this.errorTimeout = setTimeout(() => {
351
- const apiResult = this.props.apiOptions.onInputError(
352
- null, // reserved for some widget identifier
353
- this.props.value,
354
- ERROR_MESSAGE,
355
- );
356
- if (apiResult !== false) {
357
- this.setState({showErrorTooltip: true});
358
- }
359
- }, 2000);
339
+ this.setState({
340
+ invalid: false,
341
+ showErrorTooltip: false,
342
+ showErrorStyle: false,
343
+ });
344
+ if (!this.parse(this.props.value, this.props).parsed) {
345
+ const apiResult = this.props.apiOptions.onInputError(
346
+ null, // reserved for some widget identifier
347
+ this.props.value,
348
+ ERROR_MESSAGE,
349
+ );
350
+ if (apiResult !== false) {
351
+ this.setState({
352
+ invalid: true,
353
+ });
354
+ }
360
355
  }
361
356
  }
362
357
  };
363
358
 
364
359
  componentWillUnmount: () => void = () => {
365
- // TODO(jeff, CP-3128): Use Wonder Blocks Timing API.
366
- // eslint-disable-next-line no-restricted-syntax
367
- // @ts-expect-error - TS2769 - No overload matches this call.
368
- clearTimeout(this.errorTimeout);
369
-
370
360
  this._isMounted = false;
371
361
  };
372
362
 
363
+ _handleMouseDown = () => {
364
+ if (this._isMounted && this.state.showErrorTooltip) {
365
+ this.setState({
366
+ showErrorTooltip: false,
367
+ });
368
+ }
369
+ };
370
+
373
371
  simpleValidate: (
374
372
  rubric: Rubric,
375
373
  onInputError: OnInputErrorFunctionType,
@@ -422,7 +420,7 @@ export class Expression extends React.Component<Props, ExpressionState> {
422
420
  };
423
421
 
424
422
  _handleFocus: () => void = () => {
425
- this.props.analytics.onAnalyticsEvent({
423
+ this.props.analytics?.onAnalyticsEvent({
426
424
  type: "perseus:expression-focused",
427
425
  payload: null,
428
426
  });
@@ -497,7 +495,7 @@ export class Expression extends React.Component<Props, ExpressionState> {
497
495
  };
498
496
 
499
497
  sendExpressionEvaluatedEvent(result: "correct" | "incorrect" | "invalid") {
500
- this.props.analytics.onAnalyticsEvent({
498
+ this.props.analytics?.onAnalyticsEvent({
501
499
  type: "perseus:expression-evaluated",
502
500
  payload: {
503
501
  result,
@@ -536,41 +534,6 @@ export class Expression extends React.Component<Props, ExpressionState> {
536
534
  />
537
535
  );
538
536
  }
539
- // TODO(alex): Style this tooltip to be more consistent with other
540
- // tooltips on the site; align to left middle (once possible)
541
- const errorTooltip = (
542
- <span className="error-tooltip" role="tooltip">
543
- <Tooltip
544
- className="error-text-container"
545
- horizontalPosition={HorizontalDirection.Right}
546
- horizontalAlign={HorizontalDirection.Left}
547
- verticalPosition={VerticalDirection.Top}
548
- arrowSize={10}
549
- borderColor="#fcc335"
550
- show={this.state.showErrorText}
551
- >
552
- <span
553
- className="error-icon"
554
- data-test-id="test-error-icon"
555
- onMouseEnter={() => {
556
- this.setState({showErrorText: true});
557
- }}
558
- onMouseLeave={() => {
559
- this.setState({showErrorText: false});
560
- }}
561
- onClick={() => {
562
- // TODO(alex): Better error feedback for mobile
563
- this.setState({
564
- showErrorText: !this.state.showErrorText,
565
- });
566
- }}
567
- >
568
- <InlineIcon {...iconExclamationSign} />
569
- </span>
570
- <div className="error-text">{ERROR_MESSAGE}</div>
571
- </Tooltip>
572
- </span>
573
- );
574
537
 
575
538
  const className = classNames({
576
539
  "perseus-widget-expression": true,
@@ -578,20 +541,54 @@ export class Expression extends React.Component<Props, ExpressionState> {
578
541
  });
579
542
 
580
543
  return (
581
- <div className={className}>
582
- <MathInput
583
- // eslint-disable-next-line react/no-string-refs
584
- ref="input"
585
- className={ApiClassNames.INTERACTIVE}
586
- value={this.props.value}
587
- onChange={this.changeAndTrack}
588
- convertDotToTimes={this.props.times}
589
- buttonsVisible={this.props.buttonsVisible || "focused"}
590
- buttonSets={this.props.buttonSets}
591
- onFocus={this._handleFocus}
592
- onBlur={this._handleBlur}
593
- />
594
- {this.state.showErrorTooltip && errorTooltip}
544
+ <div
545
+ className={className}
546
+ onBlur={() =>
547
+ this.state.invalid &&
548
+ this.setState({
549
+ showErrorTooltip: true,
550
+ showErrorStyle: true,
551
+ })
552
+ }
553
+ onFocus={() =>
554
+ this.setState({
555
+ showErrorTooltip: false,
556
+ })
557
+ }
558
+ >
559
+ {/**
560
+ * This is a visually hidden container for the error tooltip.
561
+ https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Roles/alert_role#example_3_visually_hidden_alert_container_for_screen_reader_notifications
562
+ */}
563
+ <View style={a11y.srOnly} role="alert">
564
+ {this.state.showErrorTooltip &&
565
+ ERROR_TITLE + " " + ERROR_MESSAGE}
566
+ </View>
567
+ <Tooltip
568
+ forceAnchorFocusivity={false}
569
+ opened={this.state.showErrorTooltip}
570
+ title={ERROR_TITLE}
571
+ content={ERROR_MESSAGE}
572
+ >
573
+ <MathInput
574
+ // eslint-disable-next-line react/no-string-refs
575
+ ref="input"
576
+ className={ApiClassNames.INTERACTIVE}
577
+ value={this.props.value}
578
+ onChange={this.changeAndTrack}
579
+ convertDotToTimes={this.props.times}
580
+ buttonSets={this.props.buttonSets}
581
+ onFocus={this._handleFocus}
582
+ onBlur={this._handleBlur}
583
+ hasError={this.state.showErrorStyle}
584
+ extraKeys={this.props.keypadConfiguration?.extraKeys}
585
+ analytics={
586
+ this.props.analytics ?? {
587
+ onAnalyticsEvent: async () => {},
588
+ }
589
+ }
590
+ />
591
+ </Tooltip>
595
592
  </div>
596
593
  );
597
594
  }
@@ -616,8 +613,8 @@ const keypadConfigurationForProps = (
616
613
  const keypadType = KeypadType.EXPRESSION;
617
614
 
618
615
  // Extract any and all variables and constants from the answer forms.
619
- const uniqueExtraVariables: Record<string, any> = {};
620
- const uniqueExtraConstants: Record<string, any> = {};
616
+ const uniqueExtraVariables: Partial<Record<Key, boolean>> = {};
617
+ const uniqueExtraConstants: Partial<Record<Key, boolean>> = {};
621
618
  for (const answerForm of widgetOptions.answerForms) {
622
619
  const maybeExpr = KAS.parse(answerForm.value, widgetOptions);
623
620
  if (maybeExpr.parsed) {
@@ -630,12 +627,20 @@ const keypadConfigurationForProps = (
630
627
  symbol === "pi" || symbol === "theta";
631
628
  const toKey = (symbol: any) =>
632
629
  isGreek(symbol) ? symbol.toUpperCase() : symbol;
630
+ const isKey = (key: string): key is Key =>
631
+ KeyArray.includes(key as Key);
633
632
 
634
633
  for (const variable of expr.getVars()) {
635
- uniqueExtraVariables[toKey(variable)] = true;
634
+ const maybeKey = toKey(variable);
635
+ if (isKey(maybeKey)) {
636
+ uniqueExtraVariables[maybeKey] = true;
637
+ }
636
638
  }
637
639
  for (const constant of expr.getConsts()) {
638
- uniqueExtraConstants[toKey(constant)] = true;
640
+ const maybeKey = toKey(constant);
641
+ if (isKey(maybeKey)) {
642
+ uniqueExtraConstants[maybeKey] = true;
643
+ }
639
644
  }
640
645
  }
641
646
  }
@@ -18,6 +18,7 @@
18
18
  {"path": "../kmath/tsconfig-build.json"},
19
19
  {"path": "../math-input/tsconfig-build.json"},
20
20
  {"path": "../perseus-core/tsconfig-build.json"},
21
+ {"path": "../perseus-core/tsconfig-build.json"},
21
22
  {"path": "../perseus-linter/tsconfig-build.json"},
22
23
  {"path": "../pure-markdown/tsconfig-build.json"},
23
24
  {"path": "../simple-markdown/tsconfig-build.json"},