@khanacademy/perseus 0.4.3 → 1.0.0

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 (50) hide show
  1. package/.eslintrc.js +6 -1
  2. package/CHANGELOG.md +34 -0
  3. package/dist/es/index.js +1078 -1299
  4. package/dist/es/index.js.map +1 -1
  5. package/dist/index.js +1077 -1304
  6. package/dist/index.js.map +1 -1
  7. package/dist/strings.js +0 -4
  8. package/package.json +9 -8
  9. package/src/multi-items/__tests__/__snapshots__/multi-renderer_test.js.snap +110 -110
  10. package/src/perseus-api.jsx +10 -36
  11. package/src/perseus-types.js +0 -138
  12. package/src/styles/constants.js +0 -2
  13. package/src/styles/variables.less +0 -2
  14. package/src/types.js +18 -35
  15. package/src/util.js +0 -6
  16. package/src/version.js +1 -1
  17. package/src/widgets/__testdata__/base-radio_testdata.js +22 -0
  18. package/src/widgets/__tests__/__snapshots__/group_test.jsx.snap +110 -110
  19. package/src/widgets/__tests__/__snapshots__/radio_test.jsx.snap +1136 -1160
  20. package/src/widgets/__tests__/radio/base-radio_test.jsx +243 -41
  21. package/src/widgets/__tests__/radio/choice-icon_test.jsx +67 -0
  22. package/src/widgets/__tests__/radio/choice_test.jsx +201 -0
  23. package/src/widgets/__tests__/radio/focus-ring_test.jsx +23 -0
  24. package/src/widgets/__tests__/radio/option-status_test.jsx +65 -0
  25. package/src/widgets/passage/__tests__/passage-markdown_test.jsx +0 -9
  26. package/src/widgets/passage/get-line-height-for-node.js +4 -5
  27. package/src/widgets/passage/passage-markdown.jsx +176 -120
  28. package/src/widgets/passage.jsx +48 -46
  29. package/src/widgets/radio/__stories__/base-radio.stories.jsx +164 -17
  30. package/src/widgets/radio/__stories__/choice-icon.stories.jsx +202 -111
  31. package/src/widgets/radio/__stories__/choice-none-above.stories.jsx +21 -6
  32. package/src/widgets/radio/__stories__/choice.stories.jsx +138 -0
  33. package/src/widgets/radio/__stories__/focus-ring.stories.jsx +29 -23
  34. package/src/widgets/radio/__stories__/option-status.stories.jsx +93 -99
  35. package/src/widgets/radio/base-radio.jsx +483 -556
  36. package/src/widgets/radio/choice-icon/choice-icon.jsx +155 -0
  37. package/src/widgets/radio/choice-icon/cross-out-line.jsx +55 -0
  38. package/src/widgets/radio/choice-icon/library-choice-icon.jsx +219 -0
  39. package/src/widgets/radio/choice-icon/sat-choice-icon.jsx +129 -0
  40. package/src/widgets/radio/choice-icon/shared-styles.js +17 -0
  41. package/src/widgets/radio/choice-none-above.jsx +49 -40
  42. package/src/widgets/radio/choice.jsx +282 -353
  43. package/src/widgets/radio/choice.jsx.fixture.js +0 -18
  44. package/src/widgets/radio/focus-ring.jsx +29 -22
  45. package/src/widgets/radio/option-status.jsx +58 -36
  46. package/src/widgets/radio/widget.jsx +181 -136
  47. package/src/widgets/radio.jsx +16 -17
  48. package/src/widgets/radio/__stories__/cross-out-button.stories.jsx +0 -64
  49. package/src/widgets/radio/choice-icon.jsx +0 -469
  50. package/src/widgets/radio/cross-out-button.jsx +0 -134
@@ -1,469 +0,0 @@
1
- // @flow
2
- /* Component for rendering a letter icon in radio choice */
3
-
4
- import Color from "@khanacademy/wonder-blocks-color";
5
- import * as i18n from "@khanacademy/wonder-blocks-i18n";
6
- import {StyleSheet, css} from "aphrodite";
7
- import * as React from "react";
8
-
9
- import InlineIcon from "../../components/inline-icon.jsx";
10
- import {iconCheck, iconMinus} from "../../icon-paths.js";
11
- import * as styleConstants from "../../styles/constants.js";
12
-
13
- import FocusRing from "./focus-ring.jsx";
14
- import {getChoiceLetter} from "./util.js";
15
-
16
- const SAT_ICON_SIZE = 25;
17
- class SATChoiceIcon extends React.Component<{
18
- letter: string,
19
- a11yText: string,
20
- checked: boolean,
21
- correct: boolean,
22
- reviewMode: boolean,
23
- crossedOut: boolean,
24
- ...
25
- }> {
26
- // TODO(amy): figure out a better scheme for specifying these
27
- // styles that isn't such a pain to grok. See some neat ideas
28
- // from MDR in https://phabricator.khanacademy.org/D35249.
29
- constructStyles(
30
- reviewMode: boolean,
31
- correct: boolean,
32
- checked: boolean,
33
- ): {
34
- color: string,
35
- backgroundColor: ?string,
36
- borderColor: string,
37
- ...
38
- } {
39
- let backgroundColor;
40
- let borderColor = styleConstants.satBlue;
41
- let color = styleConstants.satBlue;
42
- if (reviewMode) {
43
- if (correct) {
44
- borderColor = styleConstants.satCorrectColor;
45
- color = checked
46
- ? styleConstants.white
47
- : styleConstants.satCorrectColor;
48
- backgroundColor = checked
49
- ? styleConstants.satCorrectColor
50
- : styleConstants.white;
51
- } else if (checked) {
52
- borderColor = styleConstants.satIncorrectColor;
53
- color = styleConstants.white;
54
- backgroundColor = styleConstants.satIncorrectColor;
55
- }
56
- } else if (checked) {
57
- color = styleConstants.white;
58
- backgroundColor = styleConstants.satBlue;
59
- }
60
- return {color, backgroundColor, borderColor};
61
- }
62
-
63
- render(): React.Node {
64
- const {letter, a11yText, reviewMode, checked, correct, crossedOut} =
65
- this.props;
66
- const {color, backgroundColor, borderColor} = this.constructStyles(
67
- reviewMode,
68
- correct,
69
- checked,
70
- );
71
-
72
- return (
73
- <div className={css(styles.iconWrapper)}>
74
- <div
75
- className={css(styles.satCircle)}
76
- style={{backgroundColor, borderColor}}
77
- />
78
- <div style={{color}} className={css(styles.letter)}>
79
- <span className="perseus-sr-only">{a11yText}</span>
80
- <span aria-hidden="true">{letter}</span>
81
- </div>
82
- {crossedOut && <CrossOutLine color={borderColor} sat={true} />}
83
- </div>
84
- );
85
- }
86
- }
87
-
88
- const LIBRARY_ICON_SIZE = 24;
89
-
90
- class LibraryChoiceIcon extends React.Component<{
91
- letter: string,
92
- a11yText: string,
93
- checked: boolean,
94
- crossedOut: boolean,
95
- pressed: boolean,
96
- focused: boolean,
97
- correct: ?boolean,
98
- reviewMode: boolean,
99
- showCorrectness: boolean,
100
- primaryProductColor: string,
101
- previouslyAnswered: boolean,
102
- transparentBackground?: boolean,
103
- ...
104
- }> {
105
- getChoiceInner() {
106
- const {letter, showCorrectness, correct} = this.props;
107
-
108
- if (!showCorrectness) {
109
- return letter;
110
- }
111
- if (correct) {
112
- return (
113
- <InlineIcon
114
- {...iconCheck}
115
- style={{
116
- position: "relative",
117
- top: -1,
118
- }}
119
- />
120
- );
121
- }
122
- return <InlineIcon {...iconMinus} />;
123
- }
124
-
125
- // Handle dynamic styling of the multiple choice icon. Most
126
- // MC icon styles are constant, but we do allow the caller
127
- // to specify the selected color, and thus must control styles
128
- // related to the selected state dynamically.
129
- getDynamicStyles() {
130
- const {
131
- checked,
132
- showCorrectness,
133
- pressed,
134
- primaryProductColor,
135
- correct,
136
- transparentBackground,
137
- } = this.props;
138
- if (!showCorrectness && pressed) {
139
- return {
140
- borderColor: primaryProductColor,
141
- color: primaryProductColor,
142
- backgroundColor: transparentBackground
143
- ? "transparent"
144
- : styleConstants.white,
145
- };
146
- }
147
- if (checked) {
148
- // Note: kaGreen is not only the default product color,
149
- // but also the "correctness" color
150
- const bg =
151
- showCorrectness && correct ? Color.green : primaryProductColor;
152
- return {
153
- color: styleConstants.white,
154
- backgroundColor: bg,
155
- borderColor: bg,
156
- };
157
- }
158
- return {
159
- borderColor: Color.offBlack64,
160
- color: Color.offBlack64,
161
- };
162
- }
163
-
164
- render(): React.Node {
165
- const {
166
- a11yText,
167
- checked,
168
- crossedOut,
169
- showCorrectness,
170
- correct,
171
- focused,
172
- primaryProductColor,
173
- previouslyAnswered,
174
- } = this.props;
175
-
176
- const dynamicStyles = this.getDynamicStyles();
177
-
178
- return (
179
- <div className={css(styles.iconWrapper)}>
180
- <FocusRing color={primaryProductColor} visible={focused}>
181
- <div
182
- style={dynamicStyles}
183
- className={css(
184
- styles.libraryCircle,
185
- showCorrectness &&
186
- correct &&
187
- styles.libraryCircleCorrect,
188
- showCorrectness &&
189
- !correct &&
190
- styles.libraryCircleIncorrect,
191
- showCorrectness &&
192
- !correct &&
193
- (checked || previouslyAnswered) &&
194
- styles.libraryCircleIncorrectAnswered,
195
- )}
196
- // used in BaseRadio to check if we actually clicked on the
197
- // radio icon
198
- data-is-radio-icon={true}
199
- >
200
- <div className="perseus-sr-only">{a11yText}</div>
201
- <div aria-hidden>{this.getChoiceInner()}</div>
202
- </div>
203
- </FocusRing>
204
- {crossedOut && (
205
- <CrossOutLine color={dynamicStyles.borderColor} />
206
- )}
207
- </div>
208
- );
209
- }
210
- }
211
-
212
- // The "cross-out line" SVG is a bit bigger than the library icon, to provide
213
- // extra space for the cute rounded line cap.
214
- const CROSS_OUT_LINE_PADDING = 2;
215
- const CROSS_OUT_LINE_SIZE = LIBRARY_ICON_SIZE + CROSS_OUT_LINE_PADDING * 2;
216
- const SAT_CROSS_OUT_LINE_SIZE = SAT_ICON_SIZE + CROSS_OUT_LINE_PADDING * 2;
217
-
218
- /**
219
- * The "cross-out line" that appears over the icon when the choice has been
220
- * `crossedOut`.
221
- */
222
- function CrossOutLine(props: {color: string, sat?: boolean, ...}) {
223
- const crossOutLineSize = props.sat
224
- ? SAT_CROSS_OUT_LINE_SIZE
225
- : CROSS_OUT_LINE_SIZE;
226
- return (
227
- <svg
228
- width={crossOutLineSize}
229
- height={crossOutLineSize}
230
- viewBox={`0 0 ${crossOutLineSize} ${crossOutLineSize}`}
231
- className={css(styles.crossOutLine)}
232
- >
233
- <line
234
- // The line stretches from the bottom-left to top-right.
235
- // We don't quite go to the _very_ corner, because the cute
236
- // rounded line cap needs to bleed into our padding.
237
- x1={CROSS_OUT_LINE_PADDING}
238
- x2={crossOutLineSize - CROSS_OUT_LINE_PADDING}
239
- y1={crossOutLineSize - CROSS_OUT_LINE_PADDING}
240
- y2={CROSS_OUT_LINE_PADDING}
241
- stroke={props.color}
242
- strokeWidth="2"
243
- strokeLinecap="round"
244
- />
245
- </svg>
246
- );
247
- }
248
-
249
- type ChoiceIconProps = {|
250
- pos: number,
251
- checked: boolean,
252
- crossedOut: boolean,
253
- pressed: boolean,
254
- focused: boolean,
255
- correct: boolean,
256
- showCorrectness: boolean,
257
- // TODO(amy): if we go this "product" flag route, define this type
258
- // somewhere shared
259
- product: "sat" | "library",
260
- reviewMode: boolean,
261
- previouslyAnswered: boolean,
262
- // TODO(mdr): The CrossOutButton needs a transparent-background ChoiceIcon,
263
- // so I've added this prop. I'm not sure why we have backgrounds in the
264
- // general case, though? When does the choice container have a
265
- // non-white background, aside from SAT, which uses a different icon?
266
- transparentBackground?: boolean,
267
-
268
- primaryProductColor: string,
269
- |};
270
-
271
- class ChoiceIcon extends React.Component<ChoiceIconProps> {
272
- static defaultProps: {|primaryProductColor: string|} = {
273
- primaryProductColor: Color.blue,
274
- };
275
-
276
- a11yText(letter: string): string {
277
- const {checked, correct, crossedOut, showCorrectness} = this.props;
278
-
279
- // There are two pieces of metadata we want to add to each a11yText:
280
- // whether the answer was checked/crossed-out/neither, and whether the
281
- // answer is correct/incorrect/not-yet-revealed.
282
- //
283
- // Translation is tricky for cross-product situations like this, so
284
- // we've just enumerated all 9 possibilities as separate strings.
285
- if (showCorrectness && correct) {
286
- if (checked) {
287
- return i18n._("(Choice %(letter)s, Checked, Correct)", {
288
- letter,
289
- });
290
- }
291
- if (crossedOut) {
292
- return i18n._("(Choice %(letter)s, Crossed out, Correct)", {
293
- letter,
294
- });
295
- }
296
- return i18n._("(Choice %(letter)s, Correct)", {
297
- letter,
298
- });
299
- }
300
- if (showCorrectness && !correct) {
301
- if (checked) {
302
- return i18n._("(Choice %(letter)s, Checked, Incorrect)", {
303
- letter,
304
- });
305
- }
306
- if (crossedOut) {
307
- return i18n._("(Choice %(letter)s, Crossed out, Incorrect)", {
308
- letter,
309
- });
310
- }
311
- return i18n._("(Choice %(letter)s, Incorrect)", {
312
- letter,
313
- });
314
- }
315
- if (checked) {
316
- return i18n._("(Choice %(letter)s, Checked)", {
317
- letter,
318
- });
319
- }
320
- if (crossedOut) {
321
- return i18n._("(Choice %(letter)s, Crossed out)", {
322
- letter,
323
- });
324
- }
325
- return i18n._("(Choice %(letter)s)", {
326
- letter,
327
- });
328
- }
329
-
330
- render(): React.Node {
331
- const {
332
- pos,
333
- reviewMode,
334
- checked,
335
- crossedOut,
336
- correct,
337
- product,
338
- showCorrectness,
339
- pressed,
340
- focused,
341
- primaryProductColor,
342
- previouslyAnswered,
343
- transparentBackground,
344
- } = this.props;
345
-
346
- const letter = getChoiceLetter(pos);
347
-
348
- if (product === "sat") {
349
- return (
350
- <SATChoiceIcon
351
- letter={letter}
352
- a11yText={this.a11yText(letter)}
353
- reviewMode={reviewMode}
354
- checked={checked}
355
- correct={correct}
356
- crossedOut={crossedOut}
357
- />
358
- );
359
- }
360
- return (
361
- <LibraryChoiceIcon
362
- letter={letter}
363
- a11yText={this.a11yText(letter)}
364
- reviewMode={reviewMode}
365
- checked={checked}
366
- crossedOut={crossedOut}
367
- pressed={pressed}
368
- focused={focused}
369
- correct={correct}
370
- showCorrectness={showCorrectness}
371
- primaryProductColor={primaryProductColor}
372
- previouslyAnswered={previouslyAnswered}
373
- transparentBackground={transparentBackground}
374
- />
375
- );
376
- }
377
- }
378
-
379
- const styles = StyleSheet.create({
380
- satCircle: {
381
- display: "block",
382
- borderRadius: SAT_ICON_SIZE,
383
- borderStyle: "solid",
384
- borderWidth: 2,
385
- content: `''`,
386
- height: SAT_ICON_SIZE,
387
- width: SAT_ICON_SIZE,
388
- top: 1,
389
- left: 1,
390
- },
391
-
392
- iconWrapper: {
393
- display: "inline-block",
394
- position: "relative",
395
- },
396
-
397
- crossOutLine: {
398
- // Center the icon within the container.
399
- position: "absolute",
400
- top: `calc(50% - ${CROSS_OUT_LINE_SIZE / 2}px)`,
401
- left: `calc(50% - ${CROSS_OUT_LINE_SIZE / 2}px)`,
402
- },
403
-
404
- libraryCircle: {
405
- // Make the circle
406
- width: LIBRARY_ICON_SIZE,
407
- height: LIBRARY_ICON_SIZE,
408
- boxSizing: "border-box",
409
- borderRadius: LIBRARY_ICON_SIZE,
410
- borderStyle: "solid",
411
- borderWidth: 2,
412
-
413
- // The default icons have letters in them. Style those letters.
414
- fontFamily: styleConstants.baseFontFamily,
415
- // NOTE(emily): We explicitly set the font weight instead of using the
416
- // "bold font family" so that characters which fall back to the default
417
- // font get bolded too.
418
- fontWeight: "bold",
419
- fontSize: 12,
420
-
421
- // Center the contents of the icon.
422
- display: "flex",
423
- alignItems: "center",
424
- justifyContent: "center",
425
- // HACK(emily): I don't know why adding this line height makes the text
426
- // appear centered better than any other value, but it does. In
427
- // particular, at large zoom levels this line height does almost
428
- // nothing, but at the default size this shifts the letter down one
429
- // pixel so it is much better centered.
430
- lineHeight: "1px",
431
- },
432
-
433
- libraryCircleCorrect: {
434
- fontSize: LIBRARY_ICON_SIZE,
435
- },
436
-
437
- libraryCircleIncorrect: {
438
- fontSize: LIBRARY_ICON_SIZE,
439
- borderColor: styleConstants.gray68,
440
- color: styleConstants.gray68,
441
- },
442
-
443
- libraryCircleIncorrectAnswered: {
444
- backgroundColor: Color.red,
445
- borderColor: Color.red,
446
- color: Color.white,
447
- },
448
-
449
- letter: {
450
- // These properties make sure that this element has the exact
451
- // same size as `circle` so that we can center things
452
- // inside of it.
453
- border: "2px solid transparent",
454
- width: SAT_ICON_SIZE,
455
- height: SAT_ICON_SIZE,
456
- position: "absolute",
457
- top: 1,
458
-
459
- // Center contained items.
460
- display: "flex",
461
- alignItems: "center",
462
- justifyContent: "center",
463
-
464
- fontFamily: styleConstants.boldFontFamily,
465
- fontSize: 13,
466
- },
467
- });
468
-
469
- export default ChoiceIcon;
@@ -1,134 +0,0 @@
1
- // @flow
2
- /**
3
- * The button that appears when you open the `CrossOutMenu`, enabling the user
4
- * to toggle a choice's `crossedOut` state.
5
- */
6
- import Color from "@khanacademy/wonder-blocks-color";
7
- import * as i18n from "@khanacademy/wonder-blocks-i18n";
8
- import {LabelLarge} from "@khanacademy/wonder-blocks-typography";
9
- import {StyleSheet, css} from "aphrodite";
10
- import * as React from "react";
11
-
12
- import ChoiceIcon from "./choice-icon.jsx";
13
- import {getChoiceLetter} from "./util.js";
14
-
15
- export type CrossOutButtonProps = {
16
- onClick: () => void,
17
- onFocus: () => void,
18
- onBlur: () => void,
19
- crossedOut: boolean,
20
- pos: number,
21
- primaryProductColor?: string,
22
- disabled?: boolean,
23
- tabFocusable?: boolean,
24
- usingKeyboardFocus: boolean,
25
- // Day theme uses dark colors, for use on light backgrounds (tooltip).
26
- // Night theme uses light colors, for use on dark backgrounds (slide panel).
27
- //
28
- // TODO(mdr): The day/night naming is used in Test Prep, but I've also seen
29
- // a similar concept in another component (tooltip), as a boolean prop
30
- // named `inverted`. If one of those emerges as the standard, we should
31
- // switch to that!
32
- theme: "day" | "night",
33
- ...
34
- };
35
-
36
- export default class CrossOutButton extends React.PureComponent<CrossOutButtonProps> {
37
- static defaultProps: {|primaryProductColor: string|} = {
38
- primaryProductColor: Color.blue,
39
- };
40
-
41
- /**
42
- * A ref function for the tooltip button. Unlike most ref functions, where
43
- * we save/delete the node on mount/unmount, we instead use this handler to
44
- * auto-focus the button on mount, if appropriate.
45
- */
46
- _handleTooltipButtonRef: (tooltipButton: ?HTMLButtonElement) => void = (
47
- tooltipButton: ?HTMLButtonElement,
48
- ) => {
49
- if (tooltipButton && this.props.usingKeyboardFocus) {
50
- tooltipButton.focus();
51
- }
52
- };
53
-
54
- _getAriaLabel(): string {
55
- const {crossedOut, pos} = this.props;
56
- const letter = getChoiceLetter(pos);
57
-
58
- if (crossedOut) {
59
- return i18n._("Bring back Choice %(letter)s", {letter});
60
- }
61
- return i18n._("Cross out Choice %(letter)s", {letter});
62
- }
63
-
64
- render(): React.Element<"button"> {
65
- const {crossedOut, pos, primaryProductColor, theme} = this.props;
66
-
67
- const buttonAndTextColor =
68
- theme === "day" ? primaryProductColor : Color.white;
69
-
70
- return (
71
- <button
72
- type="button"
73
- aria-label={this._getAriaLabel()}
74
- className={css([styles.tooltipButton])}
75
- onClick={() => this.props.onClick()}
76
- // Don't focus this element on mousedown. It's visually
77
- // distracting, and confuses our "should we shift focus"
78
- // logic for keyboard users.
79
- onMouseDown={(e) => e.preventDefault()}
80
- onFocus={() => this.props.onFocus()}
81
- onBlur={() => this.props.onBlur()}
82
- disabled={this.props.disabled}
83
- tabIndex={this.props.tabFocusable ? 0 : -1}
84
- ref={this._handleTooltipButtonRef}
85
- >
86
- <ChoiceIcon
87
- pos={pos}
88
- product="library"
89
- // This icon is a preview of the _other_ state, so we use
90
- // !crossedOut, the opposite of our current state.
91
- crossedOut={!this.props.crossedOut}
92
- // HACK(mdr): Here, we're using ChoiceIcon as a visual
93
- // element: we want a non-filled bubble, with custom
94
- // colored stroke/text rather than gray stroke/text.
95
- // Unfortunately for us, ChoiceIcon has semantic props,
96
- // so we have to hardcode the semantics that give us
97
- // the visuals we want. This is a bit confusing and a
98
- // bit fragile, but for now it seems better than adding
99
- // complexity to the ChoiceIcon API!
100
- primaryProductColor={buttonAndTextColor}
101
- pressed={true}
102
- checked={false}
103
- focused={false}
104
- correct={false}
105
- showCorrectness={false}
106
- reviewMode={false}
107
- previouslyAnswered={false}
108
- transparentBackground={true}
109
- />
110
- <LabelLarge style={{color: buttonAndTextColor}}>
111
- {crossedOut ? i18n._("Bring back") : i18n._("Cross out")}
112
- </LabelLarge>
113
- </button>
114
- );
115
- }
116
- }
117
-
118
- const styles = StyleSheet.create({
119
- tooltipButton: {
120
- // Reset <button> styles to be more like <div>.
121
- background: "none",
122
- border: "none",
123
- font: "inherit",
124
- padding: 0,
125
-
126
- cursor: "pointer",
127
- textDecoration: "none",
128
-
129
- display: "flex",
130
- flexDirection: "column",
131
- alignItems: "center",
132
- textAlign: "center",
133
- },
134
- });