@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
@@ -7,25 +7,32 @@
7
7
 
8
8
  import Color from "@khanacademy/wonder-blocks-color";
9
9
  import {View, type StyleType} from "@khanacademy/wonder-blocks-core";
10
- import {StyleSheet, css} from "aphrodite";
10
+ import {StyleSheet} from "aphrodite";
11
11
  import * as React from "react";
12
12
 
13
13
  import Icon from "../../components/icon";
14
14
  import {iconCheck, iconChevronDown, iconMinus} from "../../icon-paths";
15
15
 
16
+ import {AnswerPill} from "./answer-pill";
17
+
16
18
  import type {InteractiveMarkerType} from "./types";
19
+ import type {IconType} from "../../components/icon";
20
+ import type {AnalyticsEventHandlerFn} from "@khanacademy/perseus-core";
21
+ import type {CSSProperties} from "aphrodite";
17
22
 
18
23
  type Props = InteractiveMarkerType & {
19
24
  // Whether this marker has been selected by user.
20
25
  showSelected: boolean;
21
26
  // Whether this marker should pulsate to draw user attention.
22
27
  showPulsate: boolean;
23
- // Callbacks for when marker is interacted with using input device.
24
- onClick: (e: React.MouseEvent) => void;
25
- onKeyDown: (e: React.KeyboardEvent) => void;
26
- onFocus: () => void;
27
- onBlur: () => void;
28
+ answerSide: "top" | "bottom" | "left" | "right";
29
+ answerStyles?: CSSProperties;
30
+ showAnswer?: boolean;
31
+ analytics?: {
32
+ onAnalyticsEvent: AnalyticsEventHandlerFn;
33
+ };
28
34
  focused: boolean;
35
+ hovered: boolean;
29
36
  };
30
37
 
31
38
  function shouldReduceMotion(): boolean {
@@ -33,6 +40,8 @@ function shouldReduceMotion(): boolean {
33
40
  return !mediaQuery || mediaQuery.matches;
34
41
  }
35
42
 
43
+ const MARKER_SIZE = 24;
44
+
36
45
  export default class Marker extends React.Component<Props> {
37
46
  // The marker icon element.
38
47
  _icon: HTMLElement | null | undefined;
@@ -43,57 +52,54 @@ export default class Marker extends React.Component<Props> {
43
52
  selected: [],
44
53
  };
45
54
 
46
- renderIcon(): React.ReactElement<React.ComponentProps<"div">> {
47
- const {selected, showCorrectness, showSelected, showPulsate, focused} =
55
+ renderIcon() {
56
+ const {selected, showCorrectness, showSelected, showPulsate} =
48
57
  this.props;
49
58
 
50
59
  // Only a single marker may be "selected" at a time.
51
60
  // `showSelected` is a controlled prop, that may be set to `true` for
52
61
  // one marker at a time.
53
- // `focused` is a controlled state, driven by focus events, and may
54
- // only be `true` when there's no answer choices popup visible, and
55
- // keyboard focus is given to the marker.
56
- const isSelected = showSelected || focused;
62
+ const isOpen = showSelected;
63
+
64
+ const selectedAnswers = selected;
57
65
 
58
- let innerIcon: React.ReactElement | undefined;
59
66
  let iconStyles: StyleType;
60
67
 
68
+ const iconNull: IconType = {
69
+ path: "",
70
+ height: 0,
71
+ width: 0,
72
+ };
73
+
74
+ // default dot
75
+ let args: Icon["props"] = {
76
+ size: MARKER_SIZE,
77
+ color: Color.white,
78
+ icon: iconNull,
79
+ };
80
+
61
81
  if (showCorrectness) {
62
- innerIcon = (
63
- <Icon
64
- icon={showCorrectness === "correct" ? iconCheck : iconMinus}
65
- size={24}
66
- color={Color.white}
67
- />
68
- );
69
-
70
- if (showCorrectness === "correct") {
71
- iconStyles = [
72
- styles.markerGraded,
73
- styles.markerCorrect,
74
- isSelected && styles.markerSelected,
75
- ];
76
- } else {
77
- iconStyles = [
78
- styles.markerGraded,
79
- styles.markerIncorrect,
80
- isSelected && styles.markerSelected,
81
- ];
82
- }
83
- } else if (focused) {
84
- iconStyles = [
85
- styles.markerFocused,
86
- selected && selected.length > 0 && styles.markerFilled,
87
- showSelected && styles.markerSelected,
88
- ];
89
- } else if (selected && selected.length > 0) {
90
82
  iconStyles = [
91
- styles.markerFilled,
92
- isSelected && styles.markerSelected,
83
+ styles.markerGraded,
84
+ showCorrectness === "correct"
85
+ ? styles.markerCorrect
86
+ : styles.markerIncorrect,
87
+ isOpen && styles.markerSelected,
93
88
  ];
94
- } else if (isSelected) {
89
+ args = {
90
+ ...args,
91
+ icon: showCorrectness === "correct" ? iconCheck : iconMinus,
92
+ };
93
+ } else if (selectedAnswers && selectedAnswers.length > 0) {
94
+ iconStyles = [styles.markerFilled, isOpen && styles.markerSelected];
95
+ } else if (isOpen) {
95
96
  iconStyles = [styles.markerSelected];
96
- } else {
97
+ args = {
98
+ ...args,
99
+ icon: iconChevronDown,
100
+ size: 8,
101
+ };
102
+ } else if (showPulsate) {
97
103
  iconStyles = [
98
104
  styles.markerPulsateBase,
99
105
  shouldReduceMotion()
@@ -102,90 +108,73 @@ export default class Marker extends React.Component<Props> {
102
108
  ];
103
109
  }
104
110
 
105
- if (isSelected && selected && selected.length === 0) {
106
- innerIcon = (
107
- <Icon icon={iconChevronDown} size={8} color={Color.white} />
108
- );
109
- }
110
-
111
111
  return (
112
112
  <View
113
113
  style={[styles.markerIcon, iconStyles]}
114
114
  ref={(node) => (this._icon = node)}
115
115
  >
116
- {innerIcon}
116
+ <Icon {...args} />
117
117
  </View>
118
118
  );
119
119
  }
120
120
 
121
121
  render(): React.ReactNode {
122
- const {label, x, y, showCorrectness} = this.props;
123
-
124
- // It should no longer be possible to interact with a marker after it
125
- // has been labeled (answered) correctly.
126
- const isDisabled = showCorrectness === "correct";
122
+ const {
123
+ showCorrectness,
124
+ selected,
125
+ showAnswer,
126
+ answerSide,
127
+ answerStyles,
128
+ hovered,
129
+ focused,
130
+ } = this.props;
131
+
132
+ // We cannot make dropdown openers untabbable, so we isolate tabbing
133
+ // to answer pills when interaction is disabled.
134
+ const markerDisabled = showCorrectness === "correct";
135
+ const active = hovered || focused;
127
136
 
128
137
  return (
129
- <button
130
- aria-label={label}
131
- className={css(
132
- styles.unstyledButton,
133
- styles.marker,
134
- isDisabled && styles.disabled,
138
+ <>
139
+ <View
140
+ style={[
141
+ styles.marker,
142
+ active && !markerDisabled && styles.markerActive,
143
+ ]}
144
+ >
145
+ {this.renderIcon()}
146
+ </View>
147
+ {!!selected && showAnswer && (
148
+ <AnswerPill
149
+ selectedAnswers={selected}
150
+ showCorrectness={showCorrectness}
151
+ side={answerSide}
152
+ style={answerStyles}
153
+ markerRef={this._icon ?? undefined}
154
+ hovered={hovered}
155
+ focused={focused}
156
+ />
135
157
  )}
136
- style={{
137
- left: `${x}%`,
138
- top: `${y}%`,
139
- }}
140
- tabIndex={isDisabled ? -1 : 0}
141
- onFocus={this.props.onFocus}
142
- onBlur={this.props.onBlur}
143
- onClick={(e) => {
144
- e.preventDefault();
145
- this.props.onClick(e);
146
- }}
147
- onKeyDown={(e) => this.props.onKeyDown(e)}
148
- >
149
- {this.renderIcon()}
150
- </button>
158
+ </>
151
159
  );
152
160
  }
153
161
  }
154
162
 
155
- const lightShadowColor = "rgba(33, 36, 44, 0.16)";
156
-
157
- const markerSize = 20;
158
-
159
163
  const styles = StyleSheet.create({
160
- unstyledButton: {
161
- padding: 0,
162
-
163
- overflow: "visible",
164
-
165
- color: "inherit",
166
- font: "inherit",
167
-
168
- lineHeight: "normal",
169
-
170
- background: "none",
171
- border: "none",
172
- },
173
-
174
164
  marker: {
175
165
  position: "absolute",
176
- outline: "none",
177
166
 
178
- // Center marker position based on it's maximum size.
179
- width: markerSize,
180
- height: markerSize,
181
167
  backgroundColor: Color.white,
182
- borderRadius: 30,
183
- marginLeft: -15,
184
- marginTop: -15,
185
- },
168
+ borderRadius: MARKER_SIZE,
169
+
170
+ // Center marker position based on its maximum size.
171
+ width: MARKER_SIZE,
172
+ height: MARKER_SIZE,
173
+ marginLeft: MARKER_SIZE / -2,
174
+ marginTop: MARKER_SIZE / -2,
186
175
 
187
- disabled: {
188
- pointerEvents: "none",
176
+ // Add a shadow to the marker to make it stand out from the image.
177
+ boxShadow: `0 8px 8px ${Color.offBlack8}`,
189
178
  },
190
179
 
191
180
  // The base and unfilled marker style.
@@ -194,24 +183,13 @@ const styles = StyleSheet.create({
194
183
  alignItems: "center",
195
184
  justifyContent: "center",
196
185
 
197
- boxSizing: "content-box",
198
-
199
- width: markerSize,
200
- height: markerSize,
186
+ boxSizing: "border-box",
201
187
 
202
- marginLeft: 2,
203
-
204
- cursor: "pointer",
188
+ width: MARKER_SIZE,
189
+ height: MARKER_SIZE,
205
190
 
206
191
  border: `2px solid ${Color.offBlack64}`,
207
- borderRadius: markerSize,
208
-
209
- boxShadow: `0 8px 8px ${Color.offBlack8}`,
210
-
211
- ":hover": {
212
- outline: `2px solid ${Color.blue}`,
213
- outlineOffset: 2,
214
- },
192
+ borderRadius: MARKER_SIZE,
215
193
  },
216
194
 
217
195
  // The animation that presents the marker to the learner
@@ -239,10 +217,6 @@ const styles = StyleSheet.create({
239
217
 
240
218
  markerUnfilledPulsateInfinite: {
241
219
  animationIterationCount: "infinite",
242
-
243
- ":hover": {
244
- outline: `0px solid ${Color.blue}`,
245
- },
246
220
  },
247
221
 
248
222
  markerUnfilledPulsateOnce: {
@@ -250,7 +224,7 @@ const styles = StyleSheet.create({
250
224
  animationIterationCount: "2",
251
225
  },
252
226
 
253
- markerFocused: {
227
+ markerActive: {
254
228
  outline: `2px solid ${Color.blue}`,
255
229
  outlineOffset: 2,
256
230
  },
@@ -261,40 +235,23 @@ const styles = StyleSheet.create({
261
235
 
262
236
  border: `solid 4px ${Color.white}`,
263
237
  backgroundColor: Color.blue,
264
- borderRadius: markerSize,
238
+ borderRadius: MARKER_SIZE,
265
239
  transform: "rotate(180deg)",
266
-
267
- ":hover": {
268
- outline: `2px solid ${Color.blue}`,
269
- outlineOffset: -2,
270
- },
271
240
  },
272
241
 
273
242
  // The learner has made a selection
274
243
  markerFilled: {
275
- width: markerSize - 2,
276
- height: markerSize - 2,
277
- borderRadius: markerSize - 2,
278
244
  backgroundColor: "#ECF3FE",
279
-
280
- marginLeft: 3,
281
- marginTop: 0,
282
-
283
245
  border: `4px solid ${Color.blue}`,
284
-
285
- boxShadow: `0 1px 1px 0 ${lightShadowColor}`,
286
246
  },
287
247
 
288
248
  markerGraded: {
289
- width: markerSize,
290
- height: markerSize,
291
- marginLeft: 1,
292
- marginTop: 1,
249
+ width: MARKER_SIZE,
250
+ height: MARKER_SIZE,
293
251
 
294
252
  justifyContent: "center",
295
253
  alignItems: "center",
296
254
  border: `2px solid ${Color.white}`,
297
- boxShadow: `0 8px 8px ${Color.offBlack8}`,
298
255
  },
299
256
 
300
257
  markerCorrect: {
@@ -912,8 +912,6 @@ describe("LabelImage", function () {
912
912
  );
913
913
  userEvent.click(markerButton);
914
914
 
915
- await screen.findByRole("dialog");
916
-
917
915
  // Select a choice
918
916
  const choices = await screen.findAllByText("Trucks");
919
917
  const choice = choices[choices.length - 1];