@khanacademy/perseus 17.0.0 → 17.1.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 +66 -0
  2. package/dist/dependencies.d.ts +1 -0
  3. package/dist/es/index.css +20 -0
  4. package/dist/es/index.js +443 -338
  5. package/dist/es/index.js.map +1 -1
  6. package/dist/index.css +20 -0
  7. package/dist/index.js +444 -340
  8. package/dist/index.js.map +1 -1
  9. package/dist/perseus-types.d.ts +1 -0
  10. package/dist/strings.js +1 -0
  11. package/dist/widgets/label-image/answer-pill.d.ts +10 -0
  12. package/dist/widgets/label-image/hide-answers-toggle.d.ts +6 -0
  13. package/dist/widgets/label-image/marker.d.ts +6 -9
  14. package/dist/widgets/label-image/types.d.ts +1 -0
  15. package/dist/widgets/label-image.d.ts +101 -12
  16. package/package.json +30 -22
  17. package/src/__tests__/__snapshots__/renderer.test.tsx.snap +3 -3
  18. package/src/dependencies.ts +4 -0
  19. package/src/perseus-types.ts +2 -0
  20. package/src/styles/widgets/label-image.less +32 -0
  21. package/src/widgets/__stories__/label-image.stories.tsx +73 -3
  22. package/src/widgets/__testdata__/label-image.testdata.ts +118 -1
  23. package/src/widgets/__tests__/__snapshots__/dropdown.test.ts.snap +2 -2
  24. package/src/widgets/label-image/__stories__/answer-pill.stories.tsx +62 -0
  25. package/src/widgets/label-image/__tests__/answer-pill.test.tsx +83 -0
  26. package/src/widgets/label-image/__tests__/hide-answers-toggle.test.tsx +39 -0
  27. package/src/widgets/label-image/answer-choices.tsx +7 -1
  28. package/src/widgets/label-image/answer-pill.tsx +89 -0
  29. package/src/widgets/label-image/hide-answers-toggle.tsx +40 -0
  30. package/src/widgets/label-image/marker.tsx +116 -174
  31. package/src/widgets/label-image/types.ts +1 -0
  32. package/src/widgets/label-image.test.ts +142 -52
  33. package/src/widgets/label-image.tsx +212 -135
  34. package/tsconfig-build.tsbuildinfo +1 -1
@@ -15,19 +15,32 @@ import * as ReactDOM from "react-dom";
15
15
 
16
16
  import AssetContext from "../asset-context";
17
17
  import SvgImage from "../components/svg-image";
18
+ import {useDependencies} from "../dependencies";
18
19
  import Renderer from "../renderer";
19
20
  import {typography} from "../styles/global-styles";
20
21
  import mediaQueries from "../styles/media-queries";
21
22
 
22
23
  import AnswerChoices from "./label-image/answer-choices";
24
+ import {AnswerPill} from "./label-image/answer-pill";
25
+ import {HideAnswersToggle} from "./label-image/hide-answers-toggle";
23
26
  import Marker from "./label-image/marker";
24
27
 
25
28
  import type {
26
29
  InteractiveMarkerType,
27
30
  InteractiveMarkerScore,
28
31
  } from "./label-image/types";
32
+ import type {DependencyProps} from "../dependencies";
29
33
  import type {ChangeableProps} from "../mixins/changeable";
30
34
  import type {APIOptions, PerseusScore, WidgetExports} from "../types";
35
+ import type {PropsFor} from "@khanacademy/wonder-blocks-core";
36
+ import type {CSSProperties} from "aphrodite";
37
+
38
+ export type PreferredPopoverDirection =
39
+ | "NONE"
40
+ | "UP"
41
+ | "DOWN"
42
+ | "LEFT"
43
+ | "RIGHT";
31
44
 
32
45
  type MarkersState = {
33
46
  markers: ReadonlyArray<InteractiveMarkerType>;
@@ -56,33 +69,43 @@ type Point = {
56
69
  y: number;
57
70
  };
58
71
 
59
- type LabelImageProps = ChangeableProps & {
60
- apiOptions: APIOptions;
61
- // The list of possible answer choices.
62
- choices: ReadonlyArray<string>;
63
- // The question image properties.
64
- imageAlt: string;
65
- imageUrl: string;
66
- imageWidth: number;
67
- imageHeight: number;
68
- // The list of label markers on the question image.
69
- markers: ReadonlyArray<InteractiveMarkerType>;
70
- // Whether multiple answer choices may be selected for markers.
71
- multipleAnswers: boolean;
72
- // Whether to hide answer choices from user instructions.
73
- hideChoicesFromInstructions: boolean;
74
- // Whether the question has been answered by the user.
75
- questionCompleted: boolean;
76
- };
72
+ type LabelImageProps = ChangeableProps &
73
+ DependencyProps & {
74
+ apiOptions: APIOptions;
75
+ // The list of possible answer choices.
76
+ choices: ReadonlyArray<string>;
77
+ // The question image properties.
78
+ imageAlt: string;
79
+ imageUrl: string;
80
+ imageWidth: number;
81
+ imageHeight: number;
82
+ // The list of label markers on the question image.
83
+ markers: ReadonlyArray<InteractiveMarkerType>;
84
+ // Whether multiple answer choices may be selected for markers.
85
+ multipleAnswers: boolean;
86
+ // Whether to hide answer choices from user instructions.
87
+ hideChoicesFromInstructions: boolean;
88
+ // Whether the question has been answered by the user.
89
+ questionCompleted: boolean;
90
+ // preferred placement for popover (preference, not MUST)
91
+ preferredPopoverDirection?: PreferredPopoverDirection;
92
+ };
77
93
 
78
94
  type LabelImageState = {
79
95
  // The user selected marker index, defaults to -1, no selection.
80
- selectedMarkerIndex: number;
96
+ activeMarkerIndex: number;
81
97
  // Whether any of the markers were interacted with by the user.
82
98
  markersInteracted: boolean;
99
+ // The currently focused marker index; defaults to -1, no focus.
100
+ focusedMarkerIndex: number;
101
+ // Hide answer pills.
102
+ hideAnswers: boolean;
83
103
  };
84
104
 
85
- class LabelImage extends React.Component<LabelImageProps, LabelImageState> {
105
+ export class LabelImage extends React.Component<
106
+ LabelImageProps,
107
+ LabelImageState
108
+ > {
86
109
  // The rendered markers on the question image for labeling.
87
110
  _markers: Array<Marker | null | undefined>;
88
111
 
@@ -182,7 +205,21 @@ class LabelImage extends React.Component<LabelImageProps, LabelImageState> {
182
205
  static imageSideForMarkerPosition(
183
206
  x: number,
184
207
  y: number,
208
+ preferredDirection: PreferredPopoverDirection | undefined,
185
209
  ): "bottom" | "left" | "right" | "top" | "center" {
210
+ // Special handling for preferred size
211
+ if (preferredDirection && preferredDirection !== "NONE") {
212
+ if (preferredDirection === "LEFT" && x > 20) {
213
+ return "right";
214
+ } else if (preferredDirection === "RIGHT" && x < 80) {
215
+ return "left";
216
+ } else if (preferredDirection === "UP" && y > 20) {
217
+ return "bottom";
218
+ } else if (preferredDirection === "DOWN" && y < 80) {
219
+ return "top";
220
+ }
221
+ }
222
+
186
223
  // Special handling for when marker is positioned near the horizontal
187
224
  // edges of the image. We want to ensure the returned side would not
188
225
  // result in a popup rendering that may overflow outside the page.
@@ -337,25 +374,13 @@ class LabelImage extends React.Component<LabelImageProps, LabelImageState> {
337
374
  this._markers = [];
338
375
 
339
376
  this.state = {
340
- selectedMarkerIndex: -1,
377
+ activeMarkerIndex: -1,
378
+ focusedMarkerIndex: -1,
341
379
  markersInteracted: false,
380
+ hideAnswers: false,
342
381
  };
343
382
  }
344
383
 
345
- componentDidMount() {
346
- document.addEventListener("click", this.handleDocumentClick, true);
347
- document.addEventListener("keydown", this.handleDocumentKeyDown, true);
348
- }
349
-
350
- componentWillUnmount() {
351
- document.removeEventListener("click", this.handleDocumentClick, true);
352
- document.removeEventListener(
353
- "keydown",
354
- this.handleDocumentKeyDown,
355
- true,
356
- );
357
- }
358
-
359
384
  simpleValidate(rubric: LabelImageProps): PerseusScore {
360
385
  return LabelImage.validate(this.getUserInput(), rubric);
361
386
  }
@@ -390,15 +415,15 @@ class LabelImage extends React.Component<LabelImageProps, LabelImageState> {
390
415
  }
391
416
 
392
417
  dismissMarkerPopup() {
393
- const {selectedMarkerIndex: index} = this.state;
418
+ const {activeMarkerIndex} = this.state;
394
419
 
395
420
  // No popup should be open if there's no selected marker.
396
- if (index === -1) {
421
+ if (activeMarkerIndex === -1) {
397
422
  return;
398
423
  }
399
424
 
400
- this.setState({selectedMarkerIndex: -1}, () => {
401
- const marker = this._markers[index];
425
+ this.setState({activeMarkerIndex: -1}, () => {
426
+ const marker = this._markers[activeMarkerIndex];
402
427
  // Set focus on the just-deselected-marker, to enable to resume
403
428
  // navigating between the markers using the keyboard.
404
429
  if (marker) {
@@ -407,48 +432,6 @@ class LabelImage extends React.Component<LabelImageProps, LabelImageState> {
407
432
  });
408
433
  }
409
434
 
410
- handleDocumentClick: (e: MouseEvent) => void = (e: MouseEvent) => {
411
- // Dismiss open popup with answer choices if user clicks outside it.
412
- if (this._selectedMarkerPopup && this._answerChoices) {
413
- const answerChoices = ReactDOM.findDOMNode(this._answerChoices);
414
-
415
- const targetElement = e.target as Node;
416
- // HACK(michaelpolyak): We want to determine if the click target is
417
- // contained within the popup layer. As there's no public interface
418
- // to get this layer from the popup, we traverse several levels of
419
- // answer choices parents to test whether the click target is
420
- // contained within.
421
- const containsEventTarget = (
422
- element: Element | null | undefined | Text,
423
- depth = 3,
424
- ) =>
425
- element &&
426
- (element.contains(targetElement) ||
427
- (depth > 0 &&
428
- containsEventTarget(element.parentElement, depth - 1)));
429
-
430
- if (!containsEventTarget(answerChoices)) {
431
- // Close popup and set focus back to the marker for which it was
432
- // open.
433
- this.dismissMarkerPopup();
434
- }
435
- }
436
- };
437
-
438
- handleDocumentKeyDown: (e: KeyboardEvent) => void = (e: KeyboardEvent) => {
439
- // Dismiss open popup with answer choices if user presses Escape key.
440
- if (this._selectedMarkerPopup && e.keyCode === 27) {
441
- // Ensure other listeners are not triggered on key down event that
442
- // closes the popup, as this will also dismiss the modal that the
443
- // widget may be rendered within.
444
- e.stopPropagation();
445
-
446
- // Close popup and set focus back to the marker for which it was
447
- // open.
448
- this.dismissMarkerPopup();
449
- }
450
- };
451
-
452
435
  handleMarkerChange(index: number, marker: InteractiveMarkerType) {
453
436
  const {markers, onChange} = this.props;
454
437
 
@@ -468,16 +451,14 @@ class LabelImage extends React.Component<LabelImageProps, LabelImageState> {
468
451
  onChange({markers: updatedMarkers});
469
452
  }
470
453
 
471
- handleMarkerClick(index: number, e: MouseEvent) {
472
- const {selectedMarkerIndex} = this.state;
473
-
474
- e.preventDefault();
454
+ activateMarker(index: number) {
455
+ const {activeMarkerIndex} = this.state;
475
456
 
476
457
  // Select the marker, revealing answer choices.
477
- if (selectedMarkerIndex !== index) {
458
+ if (activeMarkerIndex !== index) {
478
459
  this.setState(
479
460
  {
480
- selectedMarkerIndex: index,
461
+ activeMarkerIndex: index,
481
462
  markersInteracted: true,
482
463
  },
483
464
  () => {
@@ -500,7 +481,7 @@ class LabelImage extends React.Component<LabelImageProps, LabelImageState> {
500
481
  }
501
482
  }
502
483
 
503
- handleMarkerKeyDown(index: number, e: KeyboardEvent) {
484
+ handleMarkerKeyDown(index: number, e: React.KeyboardEvent) {
504
485
  const {markers} = this.props;
505
486
 
506
487
  // One is the loneliest number.
@@ -545,11 +526,11 @@ class LabelImage extends React.Component<LabelImageProps, LabelImageState> {
545
526
  const {choices, markers, multipleAnswers} = this.props;
546
527
 
547
528
  // Compile the user selected answer choices.
548
- const selected = choices.filter((choice, index) => selection[index]);
529
+ const selected = choices.filter((_, index) => selection[index]);
549
530
 
550
531
  this.handleMarkerChange(index, {
551
532
  ...markers[index],
552
- selected,
533
+ selected: selected.length ? selected : undefined,
553
534
  });
554
535
 
555
536
  if (!multipleAnswers) {
@@ -579,12 +560,16 @@ class LabelImage extends React.Component<LabelImageProps, LabelImageState> {
579
560
  checked: selected ? selected.includes(choice) : false,
580
561
  }))}
581
562
  multipleSelect={multipleAnswers}
582
- onChange={(selection) =>
563
+ onChange={(selection) => {
564
+ this.props.analytics?.onAnalyticsEvent({
565
+ type: "perseus:label-image:choiced-interacted-with",
566
+ payload: null,
567
+ });
583
568
  this.handleAnswerChoicesChangeForMarker(
584
569
  index,
585
570
  selection,
586
- )
587
- }
571
+ );
572
+ }}
588
573
  ref={(node) => (this._answerChoices = node)}
589
574
  />
590
575
  </div>
@@ -592,51 +577,75 @@ class LabelImage extends React.Component<LabelImageProps, LabelImageState> {
592
577
  }
593
578
 
594
579
  renderMarkers(): ReadonlyArray<React.ReactNode> {
595
- const {markers, questionCompleted} = this.props;
580
+ const {markers, questionCompleted, preferredPopoverDirection} =
581
+ this.props;
596
582
 
597
- const {selectedMarkerIndex, markersInteracted} = this.state;
583
+ const {activeMarkerIndex, markersInteracted} = this.state;
598
584
 
599
585
  // Render all markers for widget.
600
586
  return markers.map((marker, index): React.ReactElement => {
601
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;
602
595
 
603
596
  const element = (
604
597
  <Marker
605
598
  {...marker}
606
- // Once the question is answered, show markers
607
- // with correct answers, otherwise passthrough
608
- // the correctness state.
609
- showCorrectness={
610
- questionCompleted && score.hasAnswers && score.isCorrect
611
- ? "correct"
612
- : marker.showCorrectness
613
- }
614
- showSelected={index === selectedMarkerIndex}
599
+ showCorrectness={showCorrectness}
600
+ showSelected={index === activeMarkerIndex}
615
601
  showPulsate={!markersInteracted}
616
602
  key={`${marker.x}.${marker.y}`}
617
- onClick={(e) => this.handleMarkerClick(index, e)}
603
+ onClick={() => {
604
+ this.props.analytics?.onAnalyticsEvent({
605
+ type: "perseus:label-image:marker-interacted-with",
606
+ payload: null,
607
+ });
608
+ this.activateMarker(index);
609
+ }}
618
610
  onKeyDown={(e) => this.handleMarkerKeyDown(index, e)}
619
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
+ }}
620
621
  />
621
622
  );
622
623
 
623
- // The user selected marker is wrapped with a popup that shows its
624
- // answer choices, otherwise it's returned as is.
625
- if (index !== selectedMarkerIndex) {
626
- return element;
627
- }
628
-
629
624
  // Determine whether page is rendered in a narrow browser window.
630
625
  const isNarrowPage = window.matchMedia(
631
626
  mediaQueries.xsOrSmaller.replace("@media ", ""),
632
627
  ).matches;
633
628
 
634
- let side;
629
+ // Determine whether the image is wider than it is tall.
630
+ const isWideImage =
631
+ this.props.imageWidth / 2 > this.props.imageHeight;
632
+
633
+ let side: "bottom" | "left" | "right" | "top";
634
+ let markerPosition;
635
635
  // Position popup closest to the center, preferring it renders
636
636
  // entirely within the image area.
637
- if (isNarrowPage) {
637
+ if (isNarrowPage || isWideImage) {
638
638
  side = marker.y > 50 ? "top" : "bottom";
639
+ markerPosition = marker.y > 50 ? "bottom" : "top";
639
640
  } else {
641
+ markerPosition = LabelImage.imageSideForMarkerPosition(
642
+ marker.x,
643
+ marker.y,
644
+ preferredPopoverDirection,
645
+ );
646
+ if (markerPosition === "center") {
647
+ markerPosition = "bottom";
648
+ }
640
649
  // This mirrors the calculated side of where the marker is
641
650
  // located within the image, so that popup appears closer to
642
651
  // the image center.
@@ -645,28 +654,69 @@ class LabelImage extends React.Component<LabelImageProps, LabelImageState> {
645
654
  top: "bottom",
646
655
  right: "left",
647
656
  bottom: "top",
648
- center: "top",
649
- }[LabelImage.imageSideForMarkerPosition(marker.x, marker.y)];
657
+ }[markerPosition];
650
658
  }
651
659
 
652
- // TODO(michaelpolyak): Ideally we would always render markers
653
- // wrapped in the popover. Setting `opened={false}` for those
654
- // markers that are unselected (to hide their popup), this would
655
- // keep the React tree more stable.
660
+ const answerChoicesActive = index === activeMarkerIndex;
661
+
662
+ const adjustPopoverDistance: CSSProperties = {
663
+ [side]: 15, // move the popover closer to the marker
664
+ };
665
+
666
+ const adjustPillDistance: CSSProperties = {
667
+ [`margin${
668
+ markerPosition.charAt(0).toUpperCase() +
669
+ markerPosition.slice(1)
670
+ }`]: 5, // move pill further from marker
671
+ };
672
+
673
+ const showAnswerChoice =
674
+ !answerChoicesActive && !this.state.hideAnswers;
675
+
656
676
  return (
657
- <Popover
658
- content={() => (
659
- <PopoverContentCore style={styles.choicesPopover}>
660
- {this.renderAnswerChoicesForMarker(index, marker)}
661
- </PopoverContentCore>
677
+ <div key={`answers-${marker.x}.${marker.y}`}>
678
+ <Popover
679
+ content={() => (
680
+ <PopoverContentCore
681
+ style={[
682
+ adjustPopoverDistance,
683
+ styles.choicesPopover,
684
+ ]}
685
+ >
686
+ {this.renderAnswerChoicesForMarker(
687
+ index,
688
+ marker,
689
+ )}
690
+ </PopoverContentCore>
691
+ )}
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
+ />
662
718
  )}
663
- placement={side}
664
- opened={true}
665
- key={`${marker.x}.${marker.y}`}
666
- ref={(node) => (this._selectedMarkerPopup = node)}
667
- >
668
- {element}
669
- </Popover>
719
+ </div>
670
720
  );
671
721
  });
672
722
  }
@@ -723,7 +773,7 @@ class LabelImage extends React.Component<LabelImageProps, LabelImageState> {
723
773
  render(): React.ReactNode {
724
774
  const {imageAlt, imageUrl, imageWidth, imageHeight} = this.props;
725
775
 
726
- const {selectedMarkerIndex} = this.state;
776
+ const {activeMarkerIndex} = this.state;
727
777
 
728
778
  return (
729
779
  <div>
@@ -744,7 +794,7 @@ class LabelImage extends React.Component<LabelImageProps, LabelImageState> {
744
794
  // dismiss the popup. If the image is larger in size
745
795
  // than its rendered in the widget, this would
746
796
  // result in a zoom of the image.
747
- selectedMarkerIndex !== -1 &&
797
+ activeMarkerIndex !== -1 &&
748
798
  styles.imageInteractionDisabled,
749
799
  )}
750
800
  >
@@ -762,6 +812,16 @@ class LabelImage extends React.Component<LabelImageProps, LabelImageState> {
762
812
  </div>
763
813
  {this.renderMarkers()}
764
814
  </div>
815
+ <HideAnswersToggle
816
+ areAnswersHidden={this.state.hideAnswers}
817
+ onChange={(hideAnswers) => {
818
+ this.props.analytics?.onAnalyticsEvent({
819
+ type: "perseus:label-image:toggle-answers-hidden",
820
+ payload: null,
821
+ });
822
+ this.setState({hideAnswers});
823
+ }}
824
+ />
765
825
  </div>
766
826
  );
767
827
  }
@@ -840,10 +900,27 @@ const styles = StyleSheet.create({
840
900
  },
841
901
  });
842
902
 
903
+ const LabelImageWithDependencies = React.forwardRef<
904
+ LabelImage,
905
+ Omit<PropsFor<typeof LabelImage>, keyof ReturnType<typeof useDependencies>>
906
+ >((props, ref) => {
907
+ const deps = useDependencies();
908
+ return <LabelImage ref={ref} analytics={deps.analytics} {...props} />;
909
+ });
910
+
911
+ // HACK: Propogate "static" methods onto our wrapper component.
912
+ // In the future we should adjust client apps to not depend on these static
913
+ // methods and instead adjust Peresus to provide these facilities through
914
+ // instance methods on our Renderers.
915
+ // @ts-expect-error - TS2339 - Property 'validate' does not exist on type
916
+ LabelImageWithDependencies.validate = LabelImage.validate;
917
+ // @ts-expect-error - TS2339 - Property 'gradeMarker' does not exist on type
918
+ LabelImageWithDependencies.gradeMarker = LabelImage.gradeMarker;
919
+
843
920
  export default {
844
921
  name: "label-image",
845
922
  displayName: "Label Image",
846
- widget: LabelImage,
923
+ widget: LabelImageWithDependencies,
847
924
  accessible: true,
848
925
  isLintable: true,
849
- } as WidgetExports<typeof LabelImage>;
926
+ } as WidgetExports<typeof LabelImageWithDependencies>;