@khanacademy/perseus 17.5.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 (46) hide show
  1. package/CHANGELOG.md +17 -0
  2. package/dist/es/index.js +415 -618
  3. package/dist/es/index.js.map +1 -1
  4. package/dist/index.js +424 -615
  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/iframe.tsx +1 -1
  27. package/src/widgets/input-number.tsx +1 -1
  28. package/src/widgets/label-image/__stories__/answer-choices.stories.tsx +29 -34
  29. package/src/widgets/label-image/__tests__/answer-pill.test.tsx +2 -10
  30. package/src/widgets/label-image/answer-choices.tsx +60 -278
  31. package/src/widgets/label-image/answer-pill.tsx +22 -13
  32. package/src/widgets/label-image/marker.tsx +103 -146
  33. package/src/widgets/label-image.test.ts +0 -2
  34. package/src/widgets/label-image.tsx +94 -195
  35. package/src/widgets/lights-puzzle.tsx +1 -1
  36. package/src/widgets/matcher.tsx +1 -1
  37. package/src/widgets/numeric-input.tsx +1 -1
  38. package/src/widgets/radio.ts +1 -1
  39. package/src/widgets/reaction-diagram.tsx +1 -1
  40. package/src/widgets/sequence.tsx +1 -1
  41. package/src/widgets/simulator.tsx +1 -1
  42. package/src/widgets/table.tsx +1 -1
  43. package/src/widgets/transformer.tsx +2 -1
  44. package/src/widgets/unit.tsx +1 -1
  45. package/src/widgets.ts +24 -0
  46. package/tsconfig-build.tsbuildinfo +1 -1
@@ -0,0 +1,93 @@
1
+ import "@testing-library/jest-dom"; // Imports custom matchers
2
+
3
+ import {testDependencies} from "../../../../../../testing/test-dependencies";
4
+ import * as Dependencies from "../../../dependencies";
5
+ import {renderQuestion} from "../../__tests__/renderQuestion";
6
+
7
+ const question = {
8
+ content:
9
+ "Read the excerpt and answer the question below. \n\nThe Governor and Council of the Massachusetts had much conference many days; and at last . . . . concluded a peace and friendship with [[\u2603 widget 1]], upon these conditions.",
10
+ images: {},
11
+ widgets: {
12
+ "widget 1": {
13
+ graded: true,
14
+ version: {
15
+ major: 0,
16
+ minor: 0,
17
+ },
18
+ static: false,
19
+ type: "deprecated-standin",
20
+ options: {
21
+ static: false,
22
+ },
23
+ alignment: "default",
24
+ },
25
+ },
26
+ } as const;
27
+
28
+ describe("Deprecated Standin widget", () => {
29
+ beforeEach(() => {
30
+ jest.useRealTimers();
31
+
32
+ jest.spyOn(Dependencies, "getDependencies").mockReturnValue(
33
+ testDependencies,
34
+ );
35
+ });
36
+
37
+ it("should have a default snapshot", () => {
38
+ // Arrange & Act
39
+ const {container} = renderQuestion(question);
40
+
41
+ // Assert
42
+ expect(container).toMatchSnapshot("first render");
43
+ });
44
+
45
+ it("should be scorable and always give points", () => {
46
+ // Arrange and act
47
+ const {renderer} = renderQuestion(question);
48
+ const result = renderer.scoreWidgets();
49
+
50
+ // Assert
51
+ expect(result["widget 1"]).toMatchObject({
52
+ type: "points",
53
+ earned: 1,
54
+ total: 1,
55
+ message: null,
56
+ });
57
+ });
58
+
59
+ it("should return an empty object for getUserInput()", () => {
60
+ // Arrange
61
+ const {renderer} = renderQuestion(question);
62
+
63
+ // Act
64
+ const userInput = renderer.getUserInput();
65
+
66
+ // Assert
67
+ expect(userInput).toMatchInlineSnapshot(`
68
+ [
69
+ {},
70
+ ]
71
+ `);
72
+ });
73
+
74
+ it("should return a correct answer score for simpleValidate()", () => {
75
+ // Arrange
76
+ const {renderer} = renderQuestion(question);
77
+
78
+ // Act
79
+ const score = renderer.scoreWidgets();
80
+
81
+ // Assert
82
+ expect(score).toMatchInlineSnapshot(`
83
+ {
84
+ "widget 1": {
85
+ "earned": 1,
86
+ "message": null,
87
+ "total": 1,
88
+ "type": "points",
89
+ },
90
+ }
91
+ `);
92
+ });
93
+ });
@@ -0,0 +1,57 @@
1
+ import Banner from "@khanacademy/wonder-blocks-banner";
2
+ import * as i18n from "@khanacademy/wonder-blocks-i18n";
3
+ import React from "react";
4
+
5
+ import type {PerseusScore, WidgetExports} from "../../types";
6
+
7
+ // The props are type `any` on purpose so that this can receive props
8
+ // from any deprecated widget
9
+ type AutoCorrectProps = any;
10
+ type Rubric = any;
11
+ type UserInput = Empty;
12
+
13
+ class AutoCorrect extends React.Component<AutoCorrectProps> {
14
+ static validate(userInput: UserInput, rubric: Rubric): PerseusScore {
15
+ // Since this mean to replace an existing widget the learner
16
+ // WILL earn points for this widget
17
+ return {
18
+ type: "points",
19
+ earned: 1,
20
+ total: 1,
21
+ message: null,
22
+ };
23
+ }
24
+
25
+ getUserInput: () => UserInput = () => {
26
+ return {};
27
+ };
28
+
29
+ simpleValidate: (arg1: Rubric) => PerseusScore = (rubric) => {
30
+ return AutoCorrect.validate(this.getUserInput(), rubric);
31
+ };
32
+
33
+ render() {
34
+ return (
35
+ <div
36
+ style={{
37
+ paddingTop: 8,
38
+ paddingBottom: 8,
39
+ }}
40
+ >
41
+ <Banner
42
+ text={i18n._(
43
+ "This part of the question is no-longer available. You will not be graded on this part.",
44
+ )}
45
+ kind="warning"
46
+ layout="full-width"
47
+ />
48
+ </div>
49
+ );
50
+ }
51
+ }
52
+
53
+ export default {
54
+ name: "deprecated-standin",
55
+ displayName: "Deprecated Standin",
56
+ widget: AutoCorrect,
57
+ } as WidgetExports<typeof AutoCorrect>;
@@ -188,7 +188,7 @@ _.extend(Iframe, {
188
188
 
189
189
  export default {
190
190
  name: "iframe",
191
- displayName: "Iframe",
191
+ displayName: "Iframe (deprecated)",
192
192
  widget: Iframe,
193
193
  // Let's not expose it to all content creators yet
194
194
  hidden: true,
@@ -403,7 +403,7 @@ const propTransform = (
403
403
 
404
404
  export default {
405
405
  name: "input-number",
406
- displayName: "Number text box (old)",
406
+ displayName: "Input number (deprecated - use numeric input instead)",
407
407
  defaultAlignment: "inline-block",
408
408
  hidden: true,
409
409
  widget: InputNumber,
@@ -1,9 +1,8 @@
1
+ import {LabelLarge} from "@khanacademy/wonder-blocks-typography";
1
2
  import * as React from "react";
2
3
 
3
4
  import AnswerChoices from "../answer-choices";
4
5
 
5
- import type {AnswerType} from "../answer-choices";
6
-
7
6
  type StoryArgs = Record<any, any>;
8
7
 
9
8
  type Story = {
@@ -45,47 +44,43 @@ const defaultChoices = [
45
44
  },
46
45
  ];
47
46
 
48
- class WithState extends React.Component<
49
- {
50
- multipleSelect: boolean;
51
- },
52
- {
53
- choices: ReadonlyArray<AnswerType>;
54
- }
55
- > {
56
- static defaultProps = {
57
- multipleSelect: false,
58
- };
59
-
60
- state = {
61
- choices: [...defaultChoices],
62
- };
63
-
64
- handleChange(selection) {
65
- const {choices} = this.state;
47
+ const WithState = ({multipleSelect}) => {
48
+ const [choices, setChoices] = React.useState([...defaultChoices]);
49
+ const [isOpened, setIsOpened] = React.useState(false);
66
50
 
67
- this.setState({
68
- choices: choices.map((choice, index) => ({
51
+ const handleChange = (selection) => {
52
+ setChoices([
53
+ ...choices.map((choice, index) => ({
69
54
  ...choice,
70
55
  checked: selection[index],
71
56
  })),
72
- });
73
- }
74
-
75
- render(): React.ReactNode {
76
- const {multipleSelect} = this.props;
77
-
78
- const {choices} = this.state;
57
+ ]);
58
+ };
79
59
 
80
- return (
60
+ return (
61
+ <>
81
62
  <AnswerChoices
82
63
  choices={choices}
83
64
  multipleSelect={multipleSelect}
84
- onChange={(selection) => this.handleChange(selection)}
65
+ onChange={(selection) => handleChange(selection)}
66
+ opener={() => <button>{isOpened ? "Close" : "Open"}</button>}
67
+ onToggle={(opened) => setIsOpened(opened)}
68
+ disabled={false}
85
69
  />
86
- );
87
- }
88
- }
70
+ <>
71
+ {choices
72
+ .filter(({checked}) => checked)
73
+ .map(({content}) => (
74
+ <LabelLarge key={content}>{content}</LabelLarge>
75
+ ))}
76
+ </>
77
+ </>
78
+ );
79
+ };
80
+
81
+ WithState.defaultProps = {
82
+ multipleSelect: false,
83
+ };
89
84
 
90
85
  export const SingleSelect = (args: StoryArgs): React.ReactElement => {
91
86
  return <WithState />;
@@ -17,11 +17,7 @@ describe("AnswerPill", () => {
17
17
  it("should display the correct answer string when there is only one selected answer", () => {
18
18
  const selectedAnswers = ["Answer 1"];
19
19
  const screen = render(
20
- <AnswerPill
21
- selectedAnswers={selectedAnswers}
22
- side={"top"}
23
- onClick={() => {}}
24
- />,
20
+ <AnswerPill selectedAnswers={selectedAnswers} side={"top"} />,
25
21
  {wrapper: RenderStateRoot},
26
22
  );
27
23
  expect(screen.getByText("Answer 1")).toBeInTheDocument();
@@ -30,11 +26,7 @@ describe("AnswerPill", () => {
30
26
  it("should display the correct answer string when there are multiple selected answers", () => {
31
27
  const selectedAnswers = ["Answer 1", "Answer 2", "Answer 3"];
32
28
  const screen = render(
33
- <AnswerPill
34
- selectedAnswers={selectedAnswers}
35
- side={"top"}
36
- onClick={() => {}}
37
- />,
29
+ <AnswerPill selectedAnswers={selectedAnswers} side={"top"} />,
38
30
  {wrapper: RenderStateRoot},
39
31
  );
40
32
  expect(screen.getByText("3 answers")).toBeInTheDocument();
@@ -1,19 +1,16 @@
1
1
  /**
2
- * Controlled list of answer choices, supports arrow key navigation.
3
- *
4
- * Enables single or multiple answers selection, using radio or checkbox input
5
- * types, respectively.
2
+ * Enables single or multiple answers selection using WonderBlocks dropdowns.
6
3
  */
7
4
 
8
- import Color from "@khanacademy/wonder-blocks-color";
9
- import {StyleSheet, css} from "aphrodite";
5
+ import {
6
+ MultiSelect,
7
+ SingleSelect,
8
+ OptionItem,
9
+ } from "@khanacademy/wonder-blocks-dropdown";
10
10
  import * as React from "react";
11
11
  import _ from "underscore";
12
12
 
13
- import Icon from "../../components/icon";
14
- import {iconCheckMedium} from "../../icon-paths";
15
13
  import Renderer from "../../renderer";
16
- import sharedStyles from "../../styles/shared";
17
14
 
18
15
  export type AnswerType = {
19
16
  // The answer string, can be plain text or a KaTeX expression.
@@ -22,25 +19,6 @@ export type AnswerType = {
22
19
  checked: boolean;
23
20
  };
24
21
 
25
- type AnswerProps = AnswerType & {
26
- // The input control type to use.
27
- inputType: "checkbox" | "radio";
28
- // The radio group name.
29
- groupName: string;
30
- // The answer position within choices.
31
- index: number;
32
- // Callback to handle change to answer selection.
33
- onChange: (checked: boolean) => void;
34
- // Callbacks to handle navigating between adjacent answers.
35
- onFocusPrevAnswer: () => void;
36
- onFocusNextAnswer: () => void;
37
- };
38
-
39
- type AnswerState = {
40
- // Whether answer is currently focused.
41
- isInputFocused: boolean;
42
- };
43
-
44
22
  type AnswerChoicesProps = {
45
23
  // The list of possible answers in a specific order.
46
24
  choices: ReadonlyArray<AnswerType>;
@@ -48,259 +26,63 @@ type AnswerChoicesProps = {
48
26
  multipleSelect: boolean;
49
27
  // Callback to handle change to answer choices selection.
50
28
  onChange: (selection: ReadonlyArray<boolean>) => void;
29
+ // Callback to define custom opener.
30
+ opener: SingleSelect["props"]["opener"];
31
+ // Callback to handle toggle of dropdown.
32
+ onToggle: (opened: boolean) => unknown;
33
+ // Whether the answer choices are disabled.
34
+ disabled: boolean;
51
35
  };
52
36
 
53
- type AnswerChoicesState = {
54
- // Globally unique radio group name.
55
- groupName: string;
56
- };
57
-
58
- class AnswerChoice extends React.Component<AnswerProps, AnswerState> {
59
- // The radio button or checkbox input control.
60
- _input: HTMLInputElement | null | undefined;
61
-
62
- state: AnswerState = {
63
- isInputFocused: false,
37
+ const AnswerChoices = (props: AnswerChoicesProps) => {
38
+ const onAnswerChange = (selected: string[]) => {
39
+ const {choices, onChange} = props;
40
+ onChange(choices.map((choice) => selected.includes(choice.content)));
64
41
  };
65
42
 
66
- focusInput() {
67
- if (this._input) {
68
- this._input.focus();
69
- }
70
- }
71
-
72
- handleClick(e: React.MouseEvent<HTMLDivElement>) {
73
- const {checked, onChange} = this.props;
74
-
75
- e.preventDefault();
76
-
77
- // We manually handle clicks, setting focus on the input control.
78
- this.focusInput();
79
-
80
- // And toggling the answer selection state.
81
- onChange(!checked);
82
- }
83
-
84
- handleKeyDown(e: React.KeyboardEvent<HTMLDivElement>) {
85
- const {onFocusPrevAnswer, onFocusNextAnswer} = this.props;
86
-
87
- // This may override built-in browser arrow key navigation for radio
88
- // group. Checkbox fieldset does not have such built-in capability,
89
- // thus we do this manually for both input types.
90
- switch (e.key) {
91
- case "ArrowUp":
92
- e.preventDefault();
93
- onFocusPrevAnswer();
94
- break;
95
-
96
- case "ArrowDown":
97
- e.preventDefault();
98
- onFocusNextAnswer();
99
- break;
100
- }
101
- }
102
-
103
- handleInputChange(e: React.SyntheticEvent<HTMLInputElement>) {
104
- // Prevent answer selection from double-toggling, as `handleClick` is
105
- // handling changes to selection state.
106
- e.preventDefault();
107
- }
108
-
109
- handleInputFocus() {
110
- this.setState({isInputFocused: true});
111
- }
112
-
113
- handleInputBlur() {
114
- this.setState({isInputFocused: false});
115
- }
116
-
117
- renderCheckmark(): React.ReactNode {
118
- const {checked} = this.props;
119
-
120
- return (
121
- <span className={css(styles.checkmarkContainer)}>
122
- {checked && (
123
- <Icon
124
- icon={iconCheckMedium}
125
- size={16}
126
- color={Color.offBlack}
127
- />
128
- )}
129
- </span>
130
- );
131
- }
132
-
133
- render(): React.ReactNode {
134
- const {content, checked, groupName, index, inputType} = this.props;
135
-
136
- const {isInputFocused} = this.state;
137
-
138
- const answerId = `${groupName}-${index}`;
139
-
140
- return (
141
- <div
142
- className={css(
143
- styles.answerChoice,
144
- isInputFocused && styles.answerFocused,
145
- )}
146
- onClick={(e) => this.handleClick(e)}
147
- onKeyDown={(e) => this.handleKeyDown(e)}
148
- >
149
- <span className={css(styles.answerInputWrapper)}>
150
- <input
151
- className={css(sharedStyles.perseusSrOnly)}
152
- checked={checked}
153
- name={groupName}
154
- id={answerId}
155
- type={inputType}
156
- onChange={(e) => this.handleInputChange(e)}
157
- onFocus={() => this.handleInputFocus()}
158
- onBlur={() => this.handleInputBlur()}
159
- ref={(node) => (this._input = node)}
160
- />
161
-
162
- {this.renderCheckmark()}
163
- </span>
164
-
165
- <label htmlFor={answerId}>
166
- <Renderer content={content} />
167
- </label>
168
- </div>
169
- );
170
- }
171
- }
172
-
173
- export default class AnswerChoices extends React.Component<
174
- AnswerChoicesProps,
175
- AnswerChoicesState
176
- > {
177
- // The rendered answers elements.
178
- _choices: Array<AnswerChoice | null | undefined>;
179
-
180
- constructor(props: AnswerChoicesProps) {
181
- super(props);
182
-
183
- this._choices = [];
184
-
185
- this.state = {
186
- groupName: _.uniqueId("answer_choices_"),
187
- };
188
- }
189
-
190
- focusAnswer(index = 0) {
191
- const numChoices = this.props.choices.length;
192
-
193
- // Wrap the index if necessary.
194
- if (index < 0) {
195
- index += numChoices;
196
- } else if (index >= numChoices) {
197
- index -= numChoices;
198
- }
199
-
200
- const choice = this._choices[index];
201
-
202
- if (choice) {
203
- choice.focusInput();
204
- }
205
- }
206
-
207
- onAnswerChange(index: number, checked: boolean) {
208
- const {choices, multipleSelect, onChange} = this.props;
209
-
210
- const selection: Array<boolean> = [];
211
-
212
- // Compile the selection state of answer choices. In single selection
213
- // mode (radio input type), only 0 or 1 answer may be chosen.
214
- for (let i = 0; i < choices.length; i++) {
215
- selection.push(
216
- multipleSelect
217
- ? i === index
218
- ? checked
219
- : choices[i].checked
220
- : i === index
221
- ? checked
222
- : false,
223
- );
224
- }
225
-
226
- onChange(selection);
227
- }
228
-
229
- onFocusAnswer(index: number) {
230
- this.focusAnswer(index);
231
- }
232
-
233
- render(): React.ReactNode {
234
- const {choices, multipleSelect} = this.props;
235
-
236
- const {groupName} = this.state;
237
-
238
- return (
239
- <fieldset
240
- style={{
241
- border: "none",
242
- margin: 0,
243
- padding: 0,
244
- }}
245
- >
246
- {choices.map((choice, index) => (
247
- <AnswerChoice
248
- {...choice}
249
- key={choice.content}
250
- groupName={groupName}
251
- index={index}
252
- inputType={multipleSelect ? "checkbox" : "radio"}
253
- onChange={(checked) =>
254
- this.onAnswerChange(index, checked)
255
- }
256
- onFocusPrevAnswer={() => this.onFocusAnswer(index - 1)}
257
- onFocusNextAnswer={() => this.onFocusAnswer(index + 1)}
258
- ref={(node) => (this._choices[index] = node)}
259
- />
260
- ))}
261
- </fieldset>
262
- );
263
- }
264
- }
265
-
266
- const styles = StyleSheet.create({
267
- answerChoice: {
268
- display: "flex",
269
- alignItems: "center",
270
-
271
- userSelect: "none",
272
-
273
- padding: "10px 16px 10px 0px",
274
-
275
- fontWeight: "bold",
276
-
277
- border: "solid 2px transparent",
278
- borderRadius: 2,
279
-
280
- ":active": {
281
- backgroundColor: "rgba(33, 36, 44, 0.08)",
282
- },
283
- },
284
-
285
- answerFocused: {
286
- borderColor: "#1865f2",
287
-
288
- ":active": {
289
- borderColor: "transparent",
290
- },
291
- },
292
-
293
- answerInputWrapper: {
294
- marginRight: 8,
295
- },
296
-
297
- checkmarkContainer: {
298
- display: "flex",
299
- position: "relative",
43
+ // WB Dropdown types only take an array of nodes, so we can't use a
44
+ // functional component, which can only return a single node.
45
+ const AnswerItems = (choices: readonly AnswerType[]) =>
46
+ choices.map(({content}) => (
47
+ <OptionItem
48
+ key={content}
49
+ value={content}
50
+ label={<Renderer content={content} inline />}
51
+ />
52
+ ));
53
+
54
+ const selectedValues = props.choices
55
+ .filter((choice) => choice.checked)
56
+ .map((choice) => choice.content);
57
+
58
+ const {opener, onToggle, disabled} = props;
59
+
60
+ const args: Partial<
61
+ React.ComponentProps<typeof MultiSelect> &
62
+ React.ComponentProps<typeof SingleSelect>
63
+ > = {
64
+ // reset to allow child (answer pill) to control z-index
65
+ style: {zIndex: "unset"},
66
+ children: AnswerItems(props.choices),
67
+ opener,
68
+ onToggle,
69
+ disabled,
70
+ };
300
71
 
301
- width: 16,
302
- height: 16,
72
+ return props.multipleSelect ? (
73
+ <MultiSelect
74
+ {...args}
75
+ selectedValues={selectedValues}
76
+ onChange={(selected) => onAnswerChange(selected)}
77
+ />
78
+ ) : (
79
+ <SingleSelect
80
+ {...args}
81
+ selectedValue={selectedValues[0]}
82
+ onChange={(selected) => onAnswerChange([selected])}
83
+ placeholder={props.choices[0].content} // not visible, but required
84
+ />
85
+ );
86
+ };
303
87
 
304
- marginLeft: 8,
305
- },
306
- });
88
+ export default AnswerChoices;