@khanacademy/perseus 0.3.4 → 0.4.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 (52) hide show
  1. package/CHANGELOG.md +30 -0
  2. package/dist/es/index.css +436 -1
  3. package/dist/es/index.js +8232 -5591
  4. package/dist/es/index.js.map +1 -1
  5. package/dist/index.css +436 -1
  6. package/dist/index.js +17876 -15237
  7. package/dist/index.js.map +1 -1
  8. package/dist/strings.js +0 -1
  9. package/package.json +9 -8
  10. package/src/__tests__/__snapshots__/item-renderer_test.jsx.snap +2 -3
  11. package/src/__tests__/__snapshots__/server-item-renderer_test.jsx.snap +2 -3
  12. package/src/__tests__/renderer-api_test.jsx +3 -27
  13. package/src/components/__stories__/svg-image.stories.jsx +11 -0
  14. package/src/components/fixed-to-responsive.jsx +1 -0
  15. package/src/components/input-with-examples.jsx +3 -1
  16. package/src/components/svg-image.jsx +10 -7
  17. package/src/components/text-input.jsx +65 -16
  18. package/src/definition-context.js +59 -0
  19. package/src/index.js +5 -1
  20. package/src/multi-items/__tests__/__snapshots__/multi-renderer_test.js.snap +438 -523
  21. package/src/multi-items/__tests__/multi-renderer_test.js +9 -9
  22. package/src/renderer.jsx +13 -6
  23. package/src/types.js +3 -4
  24. package/src/widgets/__stories__/definition.stories.jsx +44 -0
  25. package/src/widgets/__tests__/__snapshots__/categorizer_test.jsx.snap +76 -8
  26. package/src/widgets/__tests__/__snapshots__/graded-group-set-jipt_test.jsx.snap +6 -9
  27. package/src/widgets/__tests__/__snapshots__/graded-group-set_test.jsx.snap +2 -3
  28. package/src/widgets/__tests__/__snapshots__/grapher_test.jsx.snap +19 -2
  29. package/src/widgets/__tests__/__snapshots__/group_test.jsx.snap +338 -390
  30. package/src/widgets/__tests__/__snapshots__/image_test.jsx.snap +38 -4
  31. package/src/widgets/__tests__/__snapshots__/interactive-graph_test.jsx.snap +114 -12
  32. package/src/widgets/__tests__/__snapshots__/matrix_test.jsx.snap +120 -48
  33. package/src/widgets/__tests__/__snapshots__/numeric-input_test.jsx.snap +8 -12
  34. package/src/widgets/__tests__/__snapshots__/radio_test.jsx.snap +3615 -4517
  35. package/src/widgets/__tests__/graded-group-set-jipt_test.jsx +0 -1
  36. package/src/widgets/__tests__/grapher.cypress.jsx +236 -176
  37. package/src/widgets/__tests__/group_test.jsx +4 -4
  38. package/src/widgets/__tests__/interactive-graph.cypress.jsx +371 -291
  39. package/src/widgets/__tests__/radio_test.jsx +73 -114
  40. package/src/widgets/definition.jsx +34 -29
  41. package/src/widgets/numeric-input.jsx +2 -15
  42. package/src/widgets/radio/base-radio.jsx +1 -1
  43. package/src/widgets/radio/choice-icon.jsx +11 -10
  44. package/src/widgets/radio/choice.jsx +164 -396
  45. package/src/widgets/radio/cross-out-button.jsx +3 -5
  46. package/src/widgets/radio/option-status.jsx +4 -5
  47. package/src/widgets/radio/__stories__/cross-out-menu.stories.jsx +0 -59
  48. package/src/widgets/radio/__stories__/cross-out-tooltip.stories.jsx +0 -59
  49. package/src/widgets/radio/__stories__/toggleable-radio-button.stories.jsx +0 -28
  50. package/src/widgets/radio/cross-out-menu-wrapper.jsx +0 -492
  51. package/src/widgets/radio/cross-out-tooltip.jsx +0 -129
  52. package/src/widgets/radio/toggleable-radio-button.jsx +0 -133
@@ -1,492 +0,0 @@
1
- /* eslint-disable react/no-unsafe */
2
- // @flow
3
- /**
4
- * A wrapper for radio choice UI. This adds an ellipsis menu on its right-hand
5
- * side that, when clicked, reveals a `CrossOutButton`.
6
- *
7
- * On desktop, we reveal this button by showing a simple tooltip.
8
- *
9
- * On mobile, we reveal this button by sliding the choice content to the left,
10
- * to uncover a cross-out button underneath. (This is why we need to wrap the
11
- * content!)
12
- */
13
- import Color from "@khanacademy/wonder-blocks-color";
14
- import * as i18n from "@khanacademy/wonder-blocks-i18n";
15
- import {StyleSheet, css} from "aphrodite";
16
- import * as React from "react";
17
-
18
- import Icon from "../../components/icon.jsx";
19
- import mediaQueries from "../../styles/media-queries.js";
20
-
21
- import CrossOutButton from "./cross-out-button.jsx";
22
- import CrossOutTooltip from "./cross-out-tooltip.jsx";
23
- import {getChoiceLetter} from "./util.js";
24
-
25
- // TODO(mdr): The icon in the cross-out spec isn't this one. The spec's icon
26
- // rounder dots.
27
- const ellipsisHorizontalIcon = {
28
- path: "M27.218 6.82l0 13.578q0 2.852-1.984 4.836t-4.836 1.984l-13.578 0q-2.852 0-4.836-1.984t-1.984-4.836l0-13.578q0-2.852 1.984-4.836t4.836-1.984l13.578 0q2.852 0 4.836 1.984t1.984 4.836zm36.27 0l0 13.578q0 2.852-1.984 4.836t-4.836 1.984l-13.578 0q-2.852 0-4.836-1.984t-1.984-4.836l0-13.578q0-2.852 1.984-4.836t4.836-1.984l13.578 0q2.852 0 4.836 1.984t1.984 4.836zm36.27 0l0 13.578q0 2.852-1.984 4.836t-4.836 1.984l-13.578 0q-2.852 0-4.836-1.984t-1.984-4.836l0-13.578q0-2.852 1.984-4.836t4.836-1.984l13.578 0q2.852 0 4.836 1.984t1.984 4.836z",
29
- width: 100,
30
- height: 27.284,
31
- };
32
-
33
- type CrossOutMenuWrapperProps = {
34
- // The main radio choice UI.
35
- children: React.Node,
36
- // Whether to show the cross-out UI at all.
37
- enabled: boolean,
38
- // The choice's position in the question, e.g., `0` for choice "A".
39
- pos: number,
40
- // The "primary product color" that Perseus has been configured with.
41
- // Optional; defaults to `kaGreen`.
42
- primaryProductColor?: string,
43
- // Whether the choice is currently crossed out.
44
- crossedOut: boolean,
45
- // Callback for when the user requests to change the crossed-out state.
46
- onCrossedOutChange: (crossedOut: boolean) => void,
47
- // Whether cross out is for SAT tasks.
48
- isSatProduct?: boolean,
49
- ...
50
- };
51
-
52
- type CrossOutMenuWrapperState = {
53
- // Whether the menu area is being hovered.
54
- //
55
- // This controls the menu icon's color.
56
- hovered: boolean,
57
- // Whether the menu is currently open.
58
- //
59
- // This controls the tooltip on desktop, and the slide panel on mobile.
60
- opened: boolean,
61
- // Whether the slide panel for mobile is currently covered, even partially.
62
- //
63
- // This helps us decide what z-index to use: when covered, we want a low
64
- // z-index, to be below the content; when uncovered, we want a high enough
65
- // z-index to be clickable.
66
- slidePanelCovered: boolean,
67
- // Whether the user seems to be managing their keyboard focus. If so, we
68
- // auto-focus the `tooltipButton` once the tooltip opens.
69
- usingKeyboardFocus: boolean,
70
- // Whether `crossedOut` was true when we last opened the menu.
71
- //
72
- // This helps us keep the slide-panel button visually constant, while we
73
- // slide the content back over it.
74
- //
75
- // If we just use `this.props.crossedOut` instead, then the "Cross out"
76
- // button switches to a different "Re-enable" button _right_ after you tap
77
- // it, and then immediately starts to disappear. This is visually jarring,
78
- // and liable to produce anxiety ("wait what was that, what did I miss?").
79
- // It also gives the false impression that perhaps the user could tap very
80
- // quickly to toggle it again, which isn't true, and it's not actually an
81
- // interaction that we want to encourage: interrupting the slide animation
82
- // would be visually messy, and would be liable to produce a mis-click on
83
- // the answer choice itself instead of its menu.
84
- wasCrossedOutOnLastOpen: boolean,
85
- ...
86
- };
87
-
88
- export default class CrossOutMenuWrapper extends React.PureComponent<
89
- CrossOutMenuWrapperProps,
90
- CrossOutMenuWrapperState,
91
- > {
92
- // A reference to our menu element.
93
- _menu: ?HTMLDivElement;
94
-
95
- // A reference to our menu button element.
96
- _menuButton: ?HTMLButtonElement;
97
-
98
- // Whether keyboard focus is currently inside a CrossOutButton.
99
- _crossOutButtonIsFocused: boolean;
100
-
101
- state: CrossOutMenuWrapperState = {
102
- hovered: false,
103
- opened: false,
104
- slidePanelCovered: true,
105
- usingKeyboardFocus: false,
106
- wasCrossedOutOnLastOpen: false,
107
- };
108
-
109
- UNSAFE_componentWillReceiveProps(nextProps: CrossOutMenuWrapperProps) {
110
- // If the menu becomes disabled, it should also become closed. (This
111
- // helps us avoid permanently staying open when switching from
112
- // non-review mode to review mode!)
113
- if (!nextProps.enabled) {
114
- this._closeMenu();
115
- }
116
- }
117
-
118
- /**
119
- * Open the menu, and shift focus to the tooltip cross-out button, if
120
- * necessary.
121
- *
122
- * NOTE(mdr): We only do focus-shifting for the tooltip case, because it's
123
- * especially hard to tab-navigate to. But the slide panel button is
124
- * easy to tab to, so we don't do any special focus behavior.
125
- */
126
- _openMenu: () => void = () => {
127
- // If keyboard focus is inside this component (e.g., the user
128
- // tabbed to it and pressed Enter), set state that will cause us to
129
- // auto-focus the tooltip content once it mounts. This is helpful
130
- // if you care about element focus (e.g., primary keyboard user),
131
- // but distracting if you don't (e.g., primary mouse user).
132
- const thisComponentHasFocus =
133
- this._menu != null && this._menu.contains(document.activeElement);
134
-
135
- this.setState({
136
- opened: true,
137
- usingKeyboardFocus: thisComponentHasFocus,
138
- wasCrossedOutOnLastOpen: this.props.crossedOut,
139
- });
140
- };
141
-
142
- /**
143
- * Close the menu, and shift focus back out of the button, if necessary.
144
- */
145
- _closeMenu: () => void = () => {
146
- // If we're closing the menu, but keyboard focus is still inside the
147
- // menu tooltip, move focus back to the menu button.
148
- if (this._crossOutButtonIsFocused && this._menuButton) {
149
- this._menuButton.focus();
150
- }
151
-
152
- this.setState({opened: false, slidePanelCovered: true});
153
- };
154
-
155
- /**
156
- * When the menu is clicked, we call this handler to toggle the menu
157
- * open/closed.
158
- */
159
- _handleMenuClick: (
160
- e: SyntheticMouseEvent<HTMLElement>,
161
- willBeOpened: boolean,
162
- ) => void = (
163
- e: SyntheticMouseEvent<HTMLElement>,
164
- willBeOpened: boolean,
165
- ) => {
166
- // Toggle the `opened` state. (Instead of reading `!this.state.opened`
167
- // here, we use the value at the time the element was rendered. This
168
- // helps us avoid double-toggling if we receive events from both the
169
- // `menuButton` and `menuWrapper`.)
170
- if (willBeOpened) {
171
- this._openMenu();
172
- } else {
173
- this._closeMenu();
174
- }
175
-
176
- // This click shouldn't count as a click on the parent <label> element.
177
- // TODO(mdr): Should we move the <label> to not include this menu?
178
- e.preventDefault();
179
- };
180
-
181
- /**
182
- * When the tooltip is clicked, we call this handler to toggle the
183
- * cross-out state and close the tooltip.
184
- */
185
- _handleCrossedOutButtonClick: (willBeCrossedOut: boolean) => void = (
186
- willBeCrossedOut: boolean,
187
- ) => {
188
- // Toggle the `crossedOut` state. (Instead of reading
189
- // `!this.props.crossedOut` here, we use the value at the time the
190
- // element was rendered. This helps us avoid double-toggling if we
191
- // receive events from both the `menuButton` and `menuWrapper`.)
192
- //
193
- // NOTE(mdr): This *shouldn't* be an issue, because the current React
194
- // lifecycle seems to not give us new props during a click
195
- // handler's bubbling... but it seems good to be defensive!
196
- this.props.onCrossedOutChange(willBeCrossedOut);
197
- this._closeMenu();
198
- };
199
-
200
- /**
201
- * When we finish the slide-open animation, record that the slide panel is
202
- * now uncovered.
203
- */
204
- _handleSlideTransitionEnd: () => void = () => {
205
- if (this.state.opened) {
206
- this.setState({slidePanelCovered: false});
207
- }
208
- };
209
-
210
- /**
211
- * Build props for the `CrossOutButton`, which we'll use in both the
212
- * tooltip's button (desktop) and the slide panel's button (mobile).
213
- */
214
- _getButtonProps(theme: "day" | "night"): {|
215
- crossedOut: boolean,
216
- disabled?: boolean,
217
- onBlur: () => void,
218
- onClick: () => void,
219
- onFocus: () => void,
220
- pos: number,
221
- primaryProductColor?: string,
222
- tabFocusable?: boolean,
223
- theme: "day" | "night",
224
- usingKeyboardFocus: boolean,
225
- |} {
226
- const wasCrossedOut = this.state.wasCrossedOutOnLastOpen;
227
-
228
- return {
229
- onClick: () => {
230
- this._handleCrossedOutButtonClick(!wasCrossedOut);
231
- },
232
- onFocus: () => {
233
- this._crossOutButtonIsFocused = true;
234
- },
235
- onBlur: () => {
236
- this._crossOutButtonIsFocused = false;
237
- },
238
- crossedOut: wasCrossedOut,
239
- pos: this.props.pos,
240
- primaryProductColor: this.props.primaryProductColor,
241
- usingKeyboardFocus: this.state.usingKeyboardFocus,
242
- theme,
243
- };
244
- }
245
-
246
- _getMenuIconColor(): void | string {
247
- if (this.state.opened) {
248
- return this.props.primaryProductColor;
249
- }
250
- if (this.state.hovered) {
251
- return Color.offBlack;
252
- }
253
- return Color.offBlack50;
254
- }
255
-
256
- _renderMenu(): React.Element<"div"> {
257
- const {pos, isSatProduct} = this.props;
258
- const {opened} = this.state;
259
- const menuStyles = css(styles.menu, isSatProduct && styles.spacing);
260
-
261
- return (
262
- // This whole wrapper is a valid click target for opening the menu,
263
- // and it's where we track hover state. But this isn't the main
264
- // _semantic_ click target, and it's not focusable; see the
265
- // <button>.
266
- <div
267
- ref={(el) => (this._menu = el)}
268
- className={menuStyles}
269
- onClick={(e) => this._handleMenuClick(e, !opened)}
270
- onMouseEnter={() => this.setState({hovered: true})}
271
- onMouseLeave={() => this.setState({hovered: false})}
272
- >
273
- <CrossOutTooltip
274
- // This tooltip is "dismissed" unless the menu is open.
275
- dismissed={!this.state.opened}
276
- onDismiss={this._closeMenu}
277
- buttonProps={this._getButtonProps("day")}
278
- >
279
- {/* This button is the main semantic click target. It
280
- * receives focus when you tab to it, and has a nice small
281
- * square focus outline. But the entire wrapper element is
282
- * a valid click target, too! */}
283
- <button
284
- type="button"
285
- aria-label={i18n._("Open menu for Choice %(letter)s", {
286
- letter: getChoiceLetter(pos),
287
- })}
288
- className={css([styles.menuButton])}
289
- onClick={(e) => this._handleMenuClick(e, !opened)}
290
- // Don't focus this element on mousedown. It's visually
291
- // distracting, and confuses our "should we shift focus"
292
- // logic for keyboard users.
293
- onMouseDown={(e) => e.preventDefault()}
294
- ref={(el) => (this._menuButton = el)}
295
- >
296
- <Icon
297
- className={css(styles.menuIcon)}
298
- icon={ellipsisHorizontalIcon}
299
- size={3}
300
- color={this._getMenuIconColor()}
301
- />
302
- </button>
303
- </CrossOutTooltip>
304
- </div>
305
- );
306
- }
307
-
308
- _renderSlidePanel(): React.Element<"div"> {
309
- const buttonProps = this._getButtonProps("night");
310
- const {slidePanelCovered} = this.state;
311
-
312
- return (
313
- <div
314
- // The whole slide panel acts as an extra click target, but the
315
- // semantic click target (and focusable element) is the button
316
- // itself.
317
- className={css(
318
- styles.slidePanel,
319
- slidePanelCovered && styles.slidePanelCovered,
320
- )}
321
- onClick={buttonProps.onClick}
322
- aria-hidden={slidePanelCovered}
323
- >
324
- <CrossOutButton
325
- {...buttonProps}
326
- disabled={slidePanelCovered}
327
- tabFocusable={!slidePanelCovered}
328
- />
329
- </div>
330
- );
331
- }
332
-
333
- render(): React.Element<"div"> {
334
- const {enabled} = this.props;
335
- const {opened} = this.state;
336
-
337
- // There are three main visual elements being composed here:
338
- //
339
- // 1. The choice content, provided as `this.props.children`.
340
- // 2. The menu element (ellipsis), which toggles the menu open/closed.
341
- // (The tooltip on desktop is included as part of the menu element.)
342
- // 3. The slide panel, which sits beneath the other elements unless the
343
- // menu is open and we're on mobile.
344
- //
345
- // On mobile, in order to show the slide panel, we need to slide
346
- // elements #1 and #2 to the left. So, we wrap them together in a
347
- // `slidingContent` element, which controls the actual slide animation.
348
- //
349
- // The slide panel itself is a sibling of `slideContent`, and it's
350
- // absolute-positioned relative to the wrapper. It has a negative
351
- // z-index when covered.
352
- //
353
- // Incidentally, we keep using all these same wrapper elements, even
354
- // when `this.props.enabled` is false. This helps performance when
355
- // switching from enabled (non-review mode) to disabled (review mode),
356
- // because we can reuse the same DOM tree, and just remove the menu and
357
- // slide panel elements.
358
- return (
359
- <div className={css(styles.wrapper)}>
360
- <div
361
- className={css(
362
- styles.slidingContent,
363
- enabled && styles.slidingContentEnabled,
364
- opened && styles.slidingContentOpen,
365
- )}
366
- onTransitionEnd={this._handleSlideTransitionEnd}
367
- >
368
- <div className={css(styles.content)}>
369
- {this.props.children}
370
- </div>
371
- {enabled && this._renderMenu()}
372
- </div>
373
- {enabled && this._renderSlidePanel()}
374
- </div>
375
- );
376
- }
377
- }
378
-
379
- const SLIDE_PANEL_WIDTH = 96;
380
-
381
- const styles = StyleSheet.create({
382
- wrapper: {
383
- display: "block", // HACK(mdr): Override `display: inline` in LESS :/
384
- position: "relative",
385
- },
386
-
387
- slidingContent: {
388
- display: "flex",
389
- flexDirection: "row",
390
- alignItems: "stretch",
391
- },
392
-
393
- slidingContentEnabled: {
394
- // Only apply this transition when the menu is enabled. That way, when
395
- // we switch from enabled to disabled with the menu open, we snap right
396
- // to being closed, rather than awkwardly _sliding_ closed.
397
- [mediaQueries.smOrSmaller]: {
398
- transition: "transform 0.2s ease-out",
399
- backgroundColor: Color.white,
400
- },
401
- },
402
-
403
- slidingContentOpen: {
404
- [mediaQueries.smOrSmaller]: {
405
- transform: `translateX(-${SLIDE_PANEL_WIDTH}px)`,
406
- },
407
- },
408
-
409
- content: {
410
- display: "block", // HACK(mdr): Override `display: inline` in LESS :/
411
- flex: "1 1 0",
412
- },
413
-
414
- menu: {
415
- cursor: "pointer",
416
- boxSizing: "border-box",
417
- width: 32,
418
- display: "flex",
419
- flexDirection: "column",
420
- alignItems: "flex-end",
421
- justifyContent: "center",
422
-
423
- // On phone screens, the multiple-choice answer is flush with the
424
- // viewport, which is confusing and makes for a bad click target. So,
425
- // we want to give some extra space between the dots and the edge of
426
- // the screen. But, on desktop, it's nicer to align the dots to the
427
- // right edge of the answer.
428
- //
429
- // HACK(mdr): The point at which answers _actually_ end up flush with
430
- // the viewport depends on the embedding application. This media
431
- // query is just a heuristic. But the in-between cases look fine
432
- // with the extra space anyway, so, well, thems the hacks!
433
- [mediaQueries.smOrSmaller]: {
434
- paddingRight: 8,
435
- },
436
- },
437
-
438
- menuButton: {
439
- // Reset <button> styles to be more like <div>.
440
- background: "none",
441
- border: "none",
442
- font: "inherit",
443
- padding: 0,
444
- textAlign: "inherit",
445
-
446
- cursor: "pointer",
447
- display: "flex",
448
- flexDirection: "column",
449
- justifyContent: "center",
450
- height: 16,
451
- },
452
-
453
- menuIcon: {
454
- // The target of a NewTooltip must be a block-like element.
455
- display: "block",
456
- },
457
-
458
- slidePanel: {
459
- position: "absolute",
460
- top: 0,
461
- right: 0,
462
-
463
- boxSizing: "border-box",
464
- width: SLIDE_PANEL_WIDTH,
465
- height: "100%",
466
- paddingLeft: 8,
467
- paddingRight: 8,
468
-
469
- display: "flex",
470
- flexDirection: "row",
471
- alignItems: "center",
472
- justifyContent: "center",
473
-
474
- background: Color.offBlack50,
475
- cursor: "pointer",
476
-
477
- [mediaQueries.mdOrLarger]: {
478
- display: "none",
479
- },
480
- },
481
-
482
- spacing: {
483
- paddingRight: 12,
484
- },
485
-
486
- slidePanelCovered: {
487
- // When covered, use a z-index that puts us below the block-layout
488
- // content. This also makes the slide panel unclickable, so we _only_
489
- // apply it when the panel should be covered.
490
- zIndex: -1,
491
- },
492
- });
@@ -1,129 +0,0 @@
1
- // @flow
2
- /**
3
- * The tooltip that appears when you open the `CrossOutMenu` on desktop,
4
- * enabling the user to toggle a choice's `crossedOut` state.
5
- */
6
- import * as React from "react";
7
-
8
- import NewTooltip from "../../components/new-tooltip/new-tooltip.jsx";
9
- import mediaQueries from "../../styles/media-queries.js";
10
-
11
- import CrossOutButton, {type CrossOutButtonProps} from "./cross-out-button.jsx";
12
-
13
- export type CrossOutTooltipProps = {
14
- children: React$Element<any>,
15
- dismissed: boolean,
16
- onDismiss: () => void,
17
- // Props to pass directly to the CrossOutButton.
18
- buttonProps: CrossOutButtonProps,
19
- ...
20
- };
21
-
22
- export default class CrossOutTooltip extends React.PureComponent<CrossOutTooltipProps> {
23
- /**
24
- * When you press Tab or Shift-Tab from the tooltip button, we call this
25
- * handler to dismiss the tooltip and bring you back to the menu button.
26
- */
27
- _handleKeyDown: (e: SyntheticKeyboardEvent<HTMLElement>) => void = (
28
- e: SyntheticKeyboardEvent<HTMLElement>,
29
- ) => {
30
- // We don't need to check for the shift key _also_ being applied; we
31
- // can just listen for all keystrokes where the primary key is Tab.
32
- if (e.key === "Tab") {
33
- this.props.onDismiss();
34
- e.preventDefault();
35
- }
36
- };
37
-
38
- _renderTooltipContent(): React.Element<"div"> {
39
- return (
40
- // The button is the main semantic click target of the tooltip,
41
- // which can receive tab focus (and automatically does when the
42
- // flag `usingKeyboardFocus` is set!). However, the entire tooltip
43
- // serves as a click target as well; see the <NewTooltip />.
44
- <div onKeyDown={this._handleKeyDown}>
45
- <CrossOutButton {...this.props.buttonProps} />
46
- </div>
47
- );
48
- }
49
-
50
- render(): React.Element<"div"> {
51
- return (
52
- <div>
53
- <NewTooltip
54
- content={this._renderTooltipContent()}
55
- offsetFrom="arrow"
56
- offset={10}
57
- dismissed={this.props.dismissed}
58
- showOnMount={true}
59
- toggleOnHover={false}
60
- onClick={() => this.props.buttonProps.onClick()}
61
- // On smOrSmaller, hide this tooltip, and use the slide
62
- // panel in `CrossOutMenuWrapper` instead.
63
- media={mediaQueries.mdOrLarger}
64
- >
65
- {this.props.children}
66
- </NewTooltip>
67
-
68
- {/* If the menu is open, register global dismissal handlers
69
- * that listen for clicks elsewhere in the page, or Escape
70
- * keypresses. This component renders nothing; it just
71
- * registers/unregisters handlers on mount/unmount. */}
72
- {!this.props.dismissed && (
73
- <GlobalDismissalHandlers onDismiss={this.props.onDismiss} />
74
- )}
75
- </div>
76
- );
77
- }
78
- }
79
-
80
- /**
81
- * A component that, when mounted, listens for dismissal-y events: clicking
82
- * anywhere on the page, or pressing the Escape key. We handle these events by
83
- * calling `onDismiss`.
84
- *
85
- * HACK(mdr): To generalize this component, we probably should ignore clicks
86
- * inside the tooltip itself. However, conveniently, the `onClick` handler
87
- * we receive from CrossOutMenu includes dismissal anyway. So, it's safe to
88
- * dismiss on _any_ click, not just those outside the tooltip.
89
- */
90
- class GlobalDismissalHandlers extends React.PureComponent<{
91
- onDismiss: () => void,
92
- ...
93
- }> {
94
- componentDidMount() {
95
- // Register these listeners after a tick, so they don't catch the click
96
- // or keypress that _caused_ the tooltip to mount.
97
- // TODO(jeff, CP-3128): Use Wonder Blocks Timing API
98
- // eslint-disable-next-line no-restricted-syntax
99
- setTimeout(() => {
100
- window.addEventListener("click", this._handleGlobalClick);
101
- window.addEventListener("keyup", this._handleGlobalKeyup);
102
- }, 0);
103
- }
104
-
105
- componentWillUnmount() {
106
- window.removeEventListener("click", this._handleGlobalClick);
107
- window.removeEventListener("keyup", this._handleGlobalKeyup);
108
- }
109
-
110
- /**
111
- * On clicking anywhere, trigger dismissal.
112
- */
113
- _handleGlobalClick = () => {
114
- this.props.onDismiss();
115
- };
116
-
117
- /**
118
- * If the key was Escape, trigger dismissal.
119
- */
120
- _handleGlobalKeyup = (e: KeyboardEvent) => {
121
- if (e.key === "Escape") {
122
- this.props.onDismiss();
123
- }
124
- };
125
-
126
- render(): React.Node {
127
- return null;
128
- }
129
- }