@khanacademy/perseus 17.6.0 → 17.6.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.
Files changed (34) hide show
  1. package/CHANGELOG.md +11 -0
  2. package/dist/es/index.js +402 -606
  3. package/dist/es/index.js.map +1 -1
  4. package/dist/index.js +411 -603
  5. package/dist/index.js.map +1 -1
  6. package/dist/perseus-types.d.ts +2 -1
  7. package/dist/strings.js +1 -0
  8. package/dist/widgets/deprecated-standin/index.d.ts +32 -0
  9. package/dist/widgets/label-image/answer-choices.d.ts +8 -40
  10. package/dist/widgets/label-image/answer-pill.d.ts +3 -1
  11. package/dist/widgets/label-image/marker.d.ts +10 -5
  12. package/dist/widgets/label-image.d.ts +1 -8
  13. package/dist/widgets.d.ts +9 -0
  14. package/package.json +34 -33
  15. package/src/__tests__/widgets.test.ts +20 -0
  16. package/src/extra-widgets.ts +2 -0
  17. package/src/init.ts +5 -0
  18. package/src/perseus-types.ts +4 -1
  19. package/src/util/register-all-widgets-for-testing.ts +4 -0
  20. package/src/widgets/__stories__/orderer.stories.tsx +5 -1
  21. package/src/widgets/__testdata__/orderer.testdata.ts +66 -0
  22. package/src/widgets/deprecated-standin/__stories__/deprecated-standin.stories.tsx +34 -0
  23. package/src/widgets/deprecated-standin/__tests__/__snapshots__/deprecated-standin.test.ts.snap +71 -0
  24. package/src/widgets/deprecated-standin/__tests__/deprecated-standin.test.ts +93 -0
  25. package/src/widgets/deprecated-standin/index.tsx +57 -0
  26. package/src/widgets/label-image/__stories__/answer-choices.stories.tsx +29 -34
  27. package/src/widgets/label-image/__tests__/answer-pill.test.tsx +2 -10
  28. package/src/widgets/label-image/answer-choices.tsx +60 -278
  29. package/src/widgets/label-image/answer-pill.tsx +22 -13
  30. package/src/widgets/label-image/marker.tsx +103 -146
  31. package/src/widgets/label-image.test.ts +0 -2
  32. package/src/widgets/label-image.tsx +94 -195
  33. package/src/widgets.ts +24 -0
  34. package/tsconfig-build.tsbuildinfo +1 -1
@@ -6,8 +6,9 @@
6
6
  * knowledge by directly interacting with the image.
7
7
  */
8
8
 
9
+ import Clickable from "@khanacademy/wonder-blocks-clickable";
10
+ import {View} from "@khanacademy/wonder-blocks-core";
9
11
  import * as i18n from "@khanacademy/wonder-blocks-i18n";
10
- import {Popover, PopoverContentCore} from "@khanacademy/wonder-blocks-popover";
11
12
  import {StyleSheet, css} from "aphrodite";
12
13
  import classNames from "classnames";
13
14
  import * as React from "react";
@@ -21,7 +22,6 @@ import {typography} from "../styles/global-styles";
21
22
  import mediaQueries from "../styles/media-queries";
22
23
 
23
24
  import AnswerChoices from "./label-image/answer-choices";
24
- import {AnswerPill} from "./label-image/answer-pill";
25
25
  import {HideAnswersToggle} from "./label-image/hide-answers-toggle";
26
26
  import Marker from "./label-image/marker";
27
27
 
@@ -96,8 +96,6 @@ type LabelImageState = {
96
96
  activeMarkerIndex: number;
97
97
  // Whether any of the markers were interacted with by the user.
98
98
  markersInteracted: boolean;
99
- // The currently focused marker index; defaults to -1, no focus.
100
- focusedMarkerIndex: number;
101
99
  // Hide answer pills.
102
100
  hideAnswers: boolean;
103
101
  };
@@ -109,12 +107,6 @@ export class LabelImage extends React.Component<
109
107
  // The rendered markers on the question image for labeling.
110
108
  _markers: Array<Marker | null | undefined>;
111
109
 
112
- // The popup component containing the answer choices.
113
- _selectedMarkerPopup: Popover | null | undefined;
114
-
115
- // The rendered list of answer choices for the currently selected marker.
116
- _answerChoices: AnswerChoices | null | undefined;
117
-
118
110
  static gradeMarker(marker: InteractiveMarkerType): InteractiveMarkerScore {
119
111
  const score = {
120
112
  hasAnswers: false,
@@ -375,7 +367,6 @@ export class LabelImage extends React.Component<
375
367
 
376
368
  this.state = {
377
369
  activeMarkerIndex: -1,
378
- focusedMarkerIndex: -1,
379
370
  markersInteracted: false,
380
371
  hideAnswers: false,
381
372
  };
@@ -414,24 +405,6 @@ export class LabelImage extends React.Component<
414
405
  onChange({markers: updatedMarkers}, null, true);
415
406
  }
416
407
 
417
- dismissMarkerPopup() {
418
- const {activeMarkerIndex} = this.state;
419
-
420
- // No popup should be open if there's no selected marker.
421
- if (activeMarkerIndex === -1) {
422
- return;
423
- }
424
-
425
- this.setState({activeMarkerIndex: -1}, () => {
426
- const marker = this._markers[activeMarkerIndex];
427
- // Set focus on the just-deselected-marker, to enable to resume
428
- // navigating between the markers using the keyboard.
429
- if (marker) {
430
- (ReactDOM.findDOMNode(marker) as HTMLElement).focus();
431
- }
432
- });
433
- }
434
-
435
408
  handleMarkerChange(index: number, marker: InteractiveMarkerType) {
436
409
  const {markers, onChange} = this.props;
437
410
 
@@ -451,33 +424,21 @@ export class LabelImage extends React.Component<
451
424
  onChange({markers: updatedMarkers});
452
425
  }
453
426
 
454
- activateMarker(index: number) {
455
- const {activeMarkerIndex} = this.state;
427
+ activateMarker(index: number, opened: boolean) {
428
+ this.props.analytics?.onAnalyticsEvent({
429
+ type: "perseus:label-image:marker-interacted-with",
430
+ payload: null,
431
+ });
456
432
 
457
- // Select the marker, revealing answer choices.
458
- if (activeMarkerIndex !== index) {
459
- this.setState(
460
- {
461
- activeMarkerIndex: index,
462
- markersInteracted: true,
463
- },
464
- () => {
465
- // A delay is required to allow answer choices to first render
466
- // before trying to set focus on the first choice.
467
- // TODO(jeff, CP-3128): Use Wonder Blocks Timing API
468
- // eslint-disable-next-line no-restricted-syntax
469
- setTimeout(() => {
470
- if (this._answerChoices) {
471
- this._answerChoices.focusAnswer();
472
- }
473
- }, 100);
474
- },
475
- );
433
+ const {activeMarkerIndex} = this.state;
434
+ // Set index of opened marker
435
+ if (activeMarkerIndex !== index && opened) {
436
+ this.setState({
437
+ activeMarkerIndex: index,
438
+ markersInteracted: true,
439
+ });
476
440
  } else {
477
- // The answer choices are shown within a popup attached to the
478
- // selected marker. Close the popup and set focus back to the
479
- // marker.
480
- this.dismissMarkerPopup();
441
+ this.setState({activeMarkerIndex: -1});
481
442
  }
482
443
  }
483
444
 
@@ -523,7 +484,7 @@ export class LabelImage extends React.Component<
523
484
  index: number,
524
485
  selection: ReadonlyArray<boolean>,
525
486
  ) {
526
- const {choices, markers, multipleAnswers} = this.props;
487
+ const {choices, markers} = this.props;
527
488
 
528
489
  // Compile the user selected answer choices.
529
490
  const selected = choices.filter((_, index) => selection[index]);
@@ -532,48 +493,6 @@ export class LabelImage extends React.Component<
532
493
  ...markers[index],
533
494
  selected: selected.length ? selected : undefined,
534
495
  });
535
-
536
- if (!multipleAnswers) {
537
- this.dismissMarkerPopup();
538
- }
539
- }
540
-
541
- renderAnswerChoicesForMarker(
542
- index: number,
543
- marker: InteractiveMarkerType,
544
- ): React.ReactElement<React.ComponentProps<"div">> {
545
- const {choices, multipleAnswers} = this.props;
546
-
547
- // The user selected answer choices.
548
- const selected = marker.selected;
549
-
550
- return (
551
- <div
552
- className={classNames(
553
- "perseus-label-image-widget-answer-choices",
554
- css(styles.scrollableChoices),
555
- )}
556
- >
557
- <AnswerChoices
558
- choices={choices.map((choice) => ({
559
- content: choice,
560
- checked: selected ? selected.includes(choice) : false,
561
- }))}
562
- multipleSelect={multipleAnswers}
563
- onChange={(selection) => {
564
- this.props.analytics?.onAnalyticsEvent({
565
- type: "perseus:label-image:choiced-interacted-with",
566
- payload: null,
567
- });
568
- this.handleAnswerChoicesChangeForMarker(
569
- index,
570
- selection,
571
- );
572
- }}
573
- ref={(node) => (this._answerChoices = node)}
574
- />
575
- </div>
576
- );
577
496
  }
578
497
 
579
498
  renderMarkers(): ReadonlyArray<React.ReactNode> {
@@ -584,43 +503,6 @@ export class LabelImage extends React.Component<
584
503
 
585
504
  // Render all markers for widget.
586
505
  return markers.map((marker, index): React.ReactElement => {
587
- const score = LabelImage.gradeMarker(marker);
588
- // Once the question is answered, show markers
589
- // with correct answers, otherwise passthrough
590
- // the correctness state.
591
- const showCorrectness =
592
- questionCompleted && score.hasAnswers && score.isCorrect
593
- ? "correct"
594
- : marker.showCorrectness;
595
-
596
- const element = (
597
- <Marker
598
- {...marker}
599
- showCorrectness={showCorrectness}
600
- showSelected={index === activeMarkerIndex}
601
- showPulsate={!markersInteracted}
602
- key={`${marker.x}.${marker.y}`}
603
- onClick={() => {
604
- this.props.analytics?.onAnalyticsEvent({
605
- type: "perseus:label-image:marker-interacted-with",
606
- payload: null,
607
- });
608
- this.activateMarker(index);
609
- }}
610
- onKeyDown={(e) => this.handleMarkerKeyDown(index, e)}
611
- ref={(node) => (this._markers[index] = node)}
612
- focused={index === this.state.focusedMarkerIndex}
613
- onFocus={() => {
614
- this.setState({focusedMarkerIndex: index});
615
- }}
616
- onBlur={() => {
617
- if (index === this.state.focusedMarkerIndex) {
618
- this.setState({focusedMarkerIndex: -1});
619
- }
620
- }}
621
- />
622
- );
623
-
624
506
  // Determine whether page is rendered in a narrow browser window.
625
507
  const isNarrowPage = window.matchMedia(
626
508
  mediaQueries.xsOrSmaller.replace("@media ", ""),
@@ -657,66 +539,98 @@ export class LabelImage extends React.Component<
657
539
  }[markerPosition];
658
540
  }
659
541
 
660
- const answerChoicesActive = index === activeMarkerIndex;
542
+ const score = LabelImage.gradeMarker(marker);
543
+ // Once the question is answered, show markers
544
+ // with correct answers, otherwise passthrough
545
+ // the correctness state.
546
+ const showCorrectness =
547
+ questionCompleted && score.hasAnswers && score.isCorrect
548
+ ? "correct"
549
+ : marker.showCorrectness;
661
550
 
662
- const adjustPopoverDistance: CSSProperties = {
663
- [side]: 15, // move the popover closer to the marker
664
- };
551
+ // Disable marker interaction once the question is answered correctly.
552
+ const disabled = showCorrectness === "correct";
665
553
 
666
554
  const adjustPillDistance: CSSProperties = {
667
555
  [`margin${
668
556
  markerPosition.charAt(0).toUpperCase() +
669
557
  markerPosition.slice(1)
670
- }`]: 15, // move pill further from marker
558
+ }`]: 10, // move pill further from marker
671
559
  };
672
560
 
561
+ const answerChoicesActive = index === activeMarkerIndex;
562
+
673
563
  const showAnswerChoice =
674
- !answerChoicesActive && !this.state.hideAnswers;
564
+ marker.selected &&
565
+ !answerChoicesActive &&
566
+ !this.state.hideAnswers;
675
567
 
676
568
  return (
677
- <div key={`answers-${marker.x}.${marker.y}`}>
678
- <Popover
679
- content={() => (
680
- <PopoverContentCore
681
- style={[
682
- adjustPopoverDistance,
683
- styles.choicesPopover,
684
- ]}
569
+ <View
570
+ style={{
571
+ position: "absolute",
572
+ left: `${marker.x}%`,
573
+ top: `${marker.y}%`,
574
+ // reset to allow child (answer pill) to control z-index
575
+ zIndex: "unset",
576
+ }}
577
+ >
578
+ <AnswerChoices
579
+ key={`answers-${marker.x}.${marker.y}`}
580
+ choices={this.props.choices.map((choice) => ({
581
+ content: choice,
582
+ checked: marker.selected
583
+ ? marker.selected.includes(choice)
584
+ : false,
585
+ }))}
586
+ multipleSelect={this.props.multipleAnswers}
587
+ onChange={(selection) => {
588
+ this.props.analytics?.onAnalyticsEvent({
589
+ type: "perseus:label-image:choiced-interacted-with",
590
+ payload: null,
591
+ });
592
+ this.handleAnswerChoicesChangeForMarker(
593
+ index,
594
+ selection,
595
+ );
596
+ }}
597
+ onToggle={(opened) =>
598
+ this.activateMarker(index, opened)
599
+ }
600
+ // cannot change answer choices once question is answered
601
+ disabled={disabled}
602
+ opener={({opened, text}) => (
603
+ <Clickable
604
+ role="button"
605
+ key={`marker-${marker.x}.${marker.y}`}
606
+ aria-label={
607
+ // already translated in: /widgets/graded-group.tsx
608
+ showCorrectness === "correct"
609
+ ? i18n._("Correct!")
610
+ : marker.label
611
+ }
685
612
  >
686
- {this.renderAnswerChoicesForMarker(
687
- index,
688
- marker,
613
+ {({hovered, focused, pressed}) => (
614
+ <Marker
615
+ {...marker}
616
+ showCorrectness={showCorrectness}
617
+ showSelected={opened}
618
+ showPulsate={!markersInteracted}
619
+ ref={(node) =>
620
+ (this._markers[index] = node)
621
+ }
622
+ showAnswer={showAnswerChoice}
623
+ answerSide={side}
624
+ answerStyles={adjustPillDistance}
625
+ analytics={this.props.analytics}
626
+ focused={focused || pressed}
627
+ hovered={hovered}
628
+ />
689
629
  )}
690
- </PopoverContentCore>
630
+ </Clickable>
691
631
  )}
692
- placement={side}
693
- onClose={() => this.dismissMarkerPopup()}
694
- opened={answerChoicesActive}
695
- ref={(node) => (this._selectedMarkerPopup = node)}
696
- showTail={false}
697
- dismissEnabled
698
- >
699
- {element}
700
- </Popover>
701
- {!!marker.selected && showAnswerChoice && (
702
- <AnswerPill
703
- selectedAnswers={marker.selected}
704
- showCorrectness={showCorrectness}
705
- markerRef={
706
- // this throws a warning because it's inadvisable to
707
- // call ReactDOM fns in render. we aren't storing
708
- // actual refs in this array, so not much I can do
709
- // outside of a refactor.
710
- ReactDOM.findDOMNode(
711
- this._markers[index],
712
- ) as HTMLElement
713
- }
714
- side={side}
715
- onClick={() => this.activateMarker(index)}
716
- style={adjustPillDistance}
717
- />
718
- )}
719
- </div>
632
+ />
633
+ </View>
720
634
  );
721
635
  });
722
636
  }
@@ -883,21 +797,6 @@ const styles = StyleSheet.create({
883
797
  imageInteractionDisabled: {
884
798
  pointerEvents: "none",
885
799
  },
886
-
887
- scrollableChoices: {
888
- paddingTop: 8,
889
- paddingBottom: 8,
890
-
891
- // Enable scroll for answer choices within popup on mobile phones.
892
- [mediaQueries.xsOrSmaller]: {
893
- maxHeight: 250,
894
- overflowY: "auto",
895
- },
896
- },
897
-
898
- choicesPopover: {
899
- padding: 0,
900
- },
901
800
  });
902
801
 
903
802
  const LabelImageWithDependencies = React.forwardRef<
package/src/widgets.ts CHANGED
@@ -43,6 +43,30 @@ export const registerWidgets = (widgets: ReadonlyArray<WidgetExports>) => {
43
43
  validateAlignments();
44
44
  };
45
45
 
46
+ /**
47
+ *
48
+ * @param name - the widget that you are trying to replace
49
+ * @param replacementName - the name of the widget that takes its place
50
+ *
51
+ * e.g. replaceWidget("transformer", "always-correct") will make it so the
52
+ * transformer widget is replaced by the always correct widget
53
+ */
54
+ export const replaceWidget = (name: string, replacementName: string) => {
55
+ const substituteWidget = widgets[replacementName];
56
+
57
+ if (!substituteWidget && Log) {
58
+ const errorMsg = `Failed to replace ${name} with ${replacementName}`;
59
+ Log.error(errorMsg, Errors.Internal);
60
+ return;
61
+ }
62
+
63
+ registerWidget(name, substituteWidget);
64
+
65
+ if (Log) {
66
+ Log.log(`INFO: Replacing widget ${name} with ${replacementName}`);
67
+ }
68
+ };
69
+
46
70
  export const registerEditors = (editorsToRegister: ReadonlyArray<Editor>) => {
47
71
  editorsToRegister.forEach((editor) => {
48
72
  if (!editor.widgetName) {