@khanacademy/perseus 11.6.1 → 12.0.1

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.
@@ -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
  }