@khanacademy/perseus 0.4.0 → 0.4.2

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.
@@ -1,529 +0,0 @@
1
- // @flow
2
- /**
3
- * This is TooltipPositioner, part of NewTooltip.
4
- *
5
- * This component is responsible for positioning the tooltip UI.
6
- *
7
- * (For the sake of example, we'll just talk about tooltips with side="top".
8
- * For other `side` values, swap out all the direction-y words in this
9
- * docstring accordingly!)
10
- *
11
- * First, this component measures the position of the "target point", the
12
- * top-center of the target element.
13
- *
14
- * Then, it absolute-positions (ish) a "tooltip bubble" (which holds
15
- * the tooltip contents) to have its bottom-center point exactly align (ish)
16
- * with the target point.
17
- *
18
- * Then, it absolute-positions (precisely) a "tooltip arrow" (a triangle that's
19
- * visually connected to the bubble) to point from the bottom of the tooltip
20
- * into the target element.
21
- *
22
- * Note that the tooltip bubble is positioned "ish", whereas the tooltip arrow
23
- * is positioned "precisely". In most cases, the tooltip bubble and tooltip
24
- * arrow will be centered relative to one another. However, if the tooltip
25
- * bubble can't quite fit in the available space, then it will shift to the
26
- * left or right until it fits.
27
- *
28
- * So, the tooltip bubble may not be centered on the target element, but the
29
- * tooltip arrow will be - which produces the effect that the arrow won't
30
- * always be centered on the tooltip bubble. And that's okay! It's better than
31
- * the alternative, which is to keep the bubble centered and aggressively wrap
32
- * it until it fits. (This looks verrry bad in some contexts :P)
33
- *
34
- * This flexible positioning strategy is encapsulated in the FlexiblePosition
35
- * component. Look there for more details!
36
- *
37
- * Here's a set of three examples, demonstrating the positioning strategy.
38
- * Pay special attention to the position of the arrow! (the "v" character)
39
- *
40
- * .---------------. .---------------. .---------------.
41
- * | Hello, world! | | Hello, world! | | Hello, world! |
42
- * '-v-------------' '-------v-------' '-------------v-'
43
- * <hi!> <hi!> <hi!>
44
- *
45
- * In order to keep the tooltip aligned with the target element, we listen for
46
- * events that might cause the target element to move, like window resizes, or
47
- * scroll events. This behavior is liable to cause performance issues, so we'll
48
- * need to keep an eye on this logic, and make clever compromises where
49
- * necessary!
50
- *
51
- * This component has a public method `remeasure`, to handle `remeasure` calls
52
- * passed down from NewTooltip. Calling this method will cause the bubble and
53
- * arrow to update their position.
54
- *
55
- * NOTE(mdr): This component assumes that it's mounted in the root node
56
- * provided via props. Do not attempt to mount this component in another
57
- * context.
58
- */
59
- import {StyleSheet, css} from "aphrodite";
60
- import * as React from "react";
61
-
62
- import {Errors} from "../../logging/log.js";
63
- import {PerseusError} from "../../perseus-error.js";
64
- import {
65
- zindexTooltip,
66
- zindexAboveModal,
67
- } from "../../styles/global-constants.js";
68
-
69
- import FlexiblePosition from "./flexible-position.jsx";
70
- import {
71
- arrowHeight,
72
- arrowWidth,
73
- bubbleBorderRadius,
74
- } from "./style-constants.js";
75
- import TooltipArrow from "./tooltip-arrow.jsx";
76
- import TooltipBubble from "./tooltip-bubble.jsx";
77
-
78
- import type {NewTooltipProps} from "./types.js";
79
-
80
- type TooltipPositionerProps = {
81
- tooltipProps: NewTooltipProps,
82
- dismiss: () => void,
83
- rootElement: HTMLElement,
84
- targetElement: HTMLElement,
85
- isAboveModal?: boolean,
86
- ...
87
- };
88
-
89
- type TooltipPositionerState = {
90
- targetElementClientRect: ?ClientRect,
91
- rootElementClientRect: ?ClientRect,
92
- ...
93
- };
94
-
95
- type TargetPointOffsets = {
96
- pxFromTop: number,
97
- pxFromBottom: number,
98
- pxFromLeft: number,
99
- pxFromRight: number,
100
- ...
101
- };
102
-
103
- export default class TooltipPositioner extends React.PureComponent<
104
- TooltipPositionerProps,
105
- TooltipPositionerState,
106
- > {
107
- _mountRemeasureTimeout: ?TimeoutID;
108
-
109
- state: TooltipPositionerState = {
110
- targetElementClientRect: null,
111
- rootElementClientRect: null,
112
- };
113
-
114
- componentDidMount() {
115
- // Wait a tick, then measure the elements.
116
- //
117
- // NOTE(mdr): This is necessary for all tooltips in IE and Edge, and
118
- // some tooltips in other browsers, but I don't understand why.
119
- // NewTooltip already waits a tick after mount before rendering
120
- // this React subtree, so I would've expected styles to be present
121
- // already when this element was initialized. But, without this,
122
- // many tooltips are mis-positioned on IE and Edge, and the
123
- // `CrossOutTooltip` is apparently always mispositioned, regardless
124
- // of browser. So, shrug!
125
- // TODO(jeff, WEB-1378): Use Wonder Blocks Timing API.
126
- // eslint-disable-next-line no-restricted-syntax
127
- this._mountRemeasureTimeout = setTimeout(this.remeasure, 0);
128
-
129
- window.addEventListener("resize", this.remeasure);
130
- window.addEventListener("scroll", this._handleScroll, true);
131
- }
132
-
133
- componentWillUnmount() {
134
- if (this._mountRemeasureTimeout) {
135
- // TODO(jeff, WEB-1378): Use Wonder Blocks Timing API.
136
- // eslint-disable-next-line no-restricted-syntax
137
- clearTimeout(this._mountRemeasureTimeout);
138
- }
139
- window.removeEventListener("resize", this.remeasure);
140
- window.removeEventListener("scroll", this._handleScroll, true);
141
- }
142
-
143
- /**
144
- * This is a global scroll handler, which detects all scroll events on the
145
- * page. As a quick check before proceeding, we query whether the scroll
146
- * container sits between the root and the target element. If so, this
147
- * probably affected position, and we should `remeasure` ourselves. If not,
148
- * do nothing.
149
- *
150
- * NOTE(mdr): This is roughly the same check as in VisibilityObserver.
151
- */
152
- _handleScroll: (e: Event) => void = (e: Event) => {
153
- const {rootElement, targetElement} = this.props;
154
- const scrollContainer: HTMLElement = (e.target: any);
155
-
156
- if (
157
- rootElement.contains(scrollContainer) &&
158
- scrollContainer.contains(targetElement)
159
- ) {
160
- this.remeasure();
161
- }
162
- };
163
-
164
- /**
165
- * Find the "target point" of the target element: the place where the
166
- * tooltip arrow will point.
167
- *
168
- * When side="top", this is the top-center point.
169
- * When side="bottom", this is the bottom-center point.
170
- * When side="left", this is the left-center point.
171
- * When side="right", this is the right-center point.
172
- *
173
- * Compute the target point's offsets from various sides of the screen,
174
- * and return them. We'll use these offsets to position the tooltip bubble
175
- * and arrow.
176
- */
177
- _computeTargetPointOffsets(
178
- targetElementClientRect: ClientRect,
179
- rootElementClientRect: ClientRect,
180
- ): TargetPointOffsets {
181
- const {offset, offsetFrom, side} = this.props.tooltipProps;
182
- const t = targetElementClientRect;
183
- const r = rootElementClientRect;
184
-
185
- // When `offsetFrom` is "arrow", we need to perform offset from the
186
- // arrow, rather than the bubble. To do this, we simply increase the
187
- // offset amount by the arrow's height.
188
- let totalOffset = offset;
189
- if (offsetFrom === "arrow") {
190
- totalOffset += arrowHeight;
191
- }
192
-
193
- // Compute the position of the target point, relative to the viewport.
194
- // Our choice of target point depends on the `side` prop, and will be
195
- // shifted according to the `offset` prop.
196
- let targetPoint;
197
- if (side === "top") {
198
- targetPoint = {
199
- left: t.left + t.width / 2,
200
- top: t.top - totalOffset,
201
- };
202
- } else if (side === "bottom") {
203
- targetPoint = {
204
- left: t.left + t.width / 2,
205
- top: t.bottom + totalOffset,
206
- };
207
- } else if (side === "left") {
208
- targetPoint = {
209
- left: t.left - totalOffset,
210
- top: t.top + t.height / 2,
211
- };
212
- } else if (side === "right") {
213
- targetPoint = {
214
- left: t.right + totalOffset,
215
- top: t.top + t.height / 2,
216
- };
217
- } else {
218
- throw new PerseusError(
219
- `unexpected side ${side}`,
220
- Errors.InvalidInput,
221
- );
222
- }
223
-
224
- // Take the target point, and compute its offset from each side of the
225
- // root element.
226
- //
227
- // Note that the client rect's `right` coordinate is the distance
228
- // between the element's right edge and the _left_ of the screen, not
229
- // the right of the screen. The `bottom` coordinate is similarly
230
- // unintuitive. Surprise!
231
- //
232
- // Thankfully, unlike client rects, our `pxFromRight` offset is more
233
- // what you'd expect: it's the distance between the target point and
234
- // the _right_ edge of the root element.
235
- return {
236
- pxFromTop: targetPoint.top - r.top,
237
- pxFromBottom: r.bottom - targetPoint.top,
238
- pxFromLeft: targetPoint.left - r.left,
239
- pxFromRight: r.right - targetPoint.left,
240
- };
241
- }
242
-
243
- _renderBubble(targetPoint: TargetPointOffsets): React.Element<"div"> {
244
- const {isAboveModal} = this.props;
245
- const {side, tooltipMargin} = this.props.tooltipProps;
246
-
247
- // Compute the wrapper styles and FlexiblePosition props to position
248
- // the bubble at the target point. We also apply "tooltip margin"
249
- // styles, to prevent the tooltip from getting too close to the sides
250
- // of the page. Also, in addition to the styles set here, we set one
251
- // more position coordinate in the following block.
252
- let bubblePositionerClass;
253
- const bubblePositionerStyles = {};
254
- const bubbleFlexiblePositionProps = {};
255
- const bubbleWrapperStyles = {};
256
- if (side === "top" || side === "bottom") {
257
- bubblePositionerClass = styles.tooltipBubblePositionerTopBottom;
258
- bubbleFlexiblePositionProps.direction = "horizontal";
259
- bubbleFlexiblePositionProps.pxFromStart = targetPoint.pxFromLeft;
260
- bubbleFlexiblePositionProps.pxFromEnd = targetPoint.pxFromRight;
261
- bubbleWrapperStyles.marginLeft = tooltipMargin;
262
- bubbleWrapperStyles.marginRight = tooltipMargin;
263
- } else if (side === "left" || side === "right") {
264
- bubblePositionerClass = styles.tooltipBubblePositionerLeftRight;
265
- bubbleFlexiblePositionProps.direction = "vertical";
266
- bubbleFlexiblePositionProps.pxFromStart = targetPoint.pxFromTop;
267
- bubbleFlexiblePositionProps.pxFromEnd = targetPoint.pxFromBottom;
268
- bubbleWrapperStyles.marginTop = tooltipMargin;
269
- bubbleWrapperStyles.marginBottom = tooltipMargin;
270
- } else {
271
- throw new PerseusError(
272
- `unexpected side ${side}`,
273
- Errors.InvalidInput,
274
- );
275
- }
276
-
277
- // When side="top", we position ourselves relative to the bottom of the
278
- // root, so that the tooltip's height will grow upward from the target
279
- // point.
280
- //
281
- // When side="bottom", we position ourselves relative to the top of the
282
- // root, so that the tooltip's height will grow downward from the
283
- // target point.
284
- //
285
- // When side="left", we position ourselves relative to the right of the
286
- // root, so that the tooltip's width will grow leftward from the target
287
- // point.
288
- //
289
- // When side="right", we position ourselves relative to the right of
290
- // the root, so that the tooltip's width will grow rightward from the
291
- // target point.
292
- if (side === "top") {
293
- bubblePositionerStyles.bottom = targetPoint.pxFromBottom;
294
- } else if (side === "bottom") {
295
- bubblePositionerStyles.top = targetPoint.pxFromTop;
296
- } else if (side === "left") {
297
- bubblePositionerStyles.right = targetPoint.pxFromRight;
298
- } else if (side === "right") {
299
- bubblePositionerStyles.left = targetPoint.pxFromLeft;
300
- } else {
301
- throw new PerseusError(
302
- `unexpected side ${side}`,
303
- Errors.InvalidInput,
304
- );
305
- }
306
-
307
- return (
308
- <div
309
- className={css(
310
- styles.tooltipBubblePositioner,
311
- bubblePositionerClass,
312
- isAboveModal && styles.tooltipAboveModal,
313
- )}
314
- style={bubblePositionerStyles}
315
- >
316
- <FlexiblePosition {...bubbleFlexiblePositionProps}>
317
- <div
318
- className={css(styles.tooltipBubbleWrapper)}
319
- style={bubbleWrapperStyles}
320
- >
321
- <TooltipBubble
322
- tooltipProps={this.props.tooltipProps}
323
- dismiss={this.props.dismiss}
324
- />
325
- </div>
326
- </FlexiblePosition>
327
- </div>
328
- );
329
- }
330
-
331
- _renderArrow(
332
- targetPoint: TargetPointOffsets,
333
- rootElementClientRect: ClientRect,
334
- ): React.Element<"div"> {
335
- const {isAboveModal} = this.props;
336
- const {side, tooltipMargin} = this.props.tooltipProps;
337
- const arrowStyles = {};
338
-
339
- // Compute the vertical position of the arrow (if side is top/bottom),
340
- // or horizontal position (if side is left/right). Include one extra
341
- // pixel to get the overlapping just right!
342
- //
343
- // When side="top", we position ourselves relative to the bottom of the
344
- // root element, etc., for consistency with the tooltip bubble. This
345
- // ensures that, even in the face of extreme circumstances (e.g. the
346
- // root element unexpectedly changing size), the arrow and bubble will
347
- // never separate from each other.
348
- if (side === "top") {
349
- arrowStyles.bottom = targetPoint.pxFromBottom - arrowHeight + 1;
350
- } else if (side === "bottom") {
351
- arrowStyles.top = targetPoint.pxFromTop - arrowHeight + 1;
352
- } else if (side === "left") {
353
- arrowStyles.right = targetPoint.pxFromRight - arrowHeight + 1;
354
- } else if (side === "right") {
355
- arrowStyles.left = targetPoint.pxFromLeft - arrowHeight + 1;
356
- } else {
357
- throw new PerseusError(
358
- `unexpected side ${side}`,
359
- Errors.InvalidInput,
360
- );
361
- }
362
-
363
- // Compute the horizontal position of the arrow (if side is top/bottom),
364
- // or vertical position (if side is left/right).
365
- //
366
- // While we don't care about the arrow lining up with any *exact* point
367
- // on the tooltip bubble, we *do* want to ensure that we don't align it
368
- // with one of the curved corners.
369
- //
370
- // So, we determine the minimum and maximum position value that would
371
- // be safe for the arrow to have while still leaving room for the
372
- // border radius, and clamp the value to that range.
373
- //
374
- // If the page is very narrow, then it's possible that the arrow is too
375
- // wide, and will have to exceed one bound or the other. In that case,
376
- // well, that's what you get for using a 18px-wide viewport :P
377
- const extraSpace = tooltipMargin + bubbleBorderRadius;
378
-
379
- if (side === "top" || side === "bottom") {
380
- const minLeft = extraSpace;
381
- const maxLeft =
382
- rootElementClientRect.width - arrowWidth - extraSpace;
383
- arrowStyles.left = targetPoint.pxFromLeft - arrowWidth / 2;
384
- arrowStyles.left = Math.min(arrowStyles.left, maxLeft);
385
- arrowStyles.left = Math.max(arrowStyles.left, minLeft);
386
- } else if (side === "left" || side === "right") {
387
- const minTop = extraSpace;
388
- const maxTop =
389
- rootElementClientRect.height - arrowWidth - extraSpace;
390
- arrowStyles.top = targetPoint.pxFromTop - arrowWidth / 2;
391
- arrowStyles.top = Math.min(arrowStyles.top, maxTop);
392
- arrowStyles.top = Math.max(arrowStyles.top, minTop);
393
- } else {
394
- throw new PerseusError(
395
- `unexpected side ${side}`,
396
- Errors.InvalidInput,
397
- );
398
- }
399
-
400
- return (
401
- <div
402
- className={css(
403
- styles.tooltipArrowPositioner,
404
- isAboveModal && styles.tooltipAboveModal,
405
- )}
406
- style={arrowStyles}
407
- >
408
- <TooltipArrow tooltipProps={this.props.tooltipProps} />
409
- </div>
410
- );
411
- }
412
-
413
- /**
414
- * Build an Aphrodite style that filters the tooltip's visibility by the
415
- * `media` prop provided to NewTooltip.
416
- *
417
- * When the media query is satisfied, this will have `display: block`. When
418
- * the media query is not satisfied, this will have `display: none`.
419
- *
420
- * If no `media` prop was provided, this returns null.
421
- */
422
- _buildMediaFilterStyle(): $FlowFixMe {
423
- const {media} = this.props.tooltipProps;
424
- if (!media) {
425
- return null;
426
- }
427
-
428
- // NOTE(mdr): It's not, like, _good practice_ to create Aphrodite
429
- // stylesheets inline like this, but there's no significant
430
- // negative consequences! Thanks to Aphrodite's hashing strategy,
431
- // this will always yield the same class name, even though we
432
- // rebuild the stylesheet from scratch on each render.
433
- const mediaFilterStyleSheet = StyleSheet.create({
434
- mediaFilter: {
435
- display: "none",
436
- [media]: {
437
- display: "block",
438
- },
439
- },
440
- });
441
-
442
- return mediaFilterStyleSheet.mediaFilter;
443
- }
444
-
445
- remeasure: () => void = () => {
446
- const {targetElement, rootElement} = this.props;
447
- this.setState({
448
- targetElementClientRect: targetElement.getBoundingClientRect(),
449
- rootElementClientRect: rootElement.getBoundingClientRect(),
450
- });
451
- };
452
-
453
- render(): null | React.Element<"div"> {
454
- const {targetElementClientRect, rootElementClientRect} = this.state;
455
- if (!targetElementClientRect || !rootElementClientRect) {
456
- return null;
457
- }
458
-
459
- const targetPoint = this._computeTargetPointOffsets(
460
- targetElementClientRect,
461
- rootElementClientRect,
462
- );
463
-
464
- return (
465
- <div className={css(this._buildMediaFilterStyle())}>
466
- {this._renderBubble(targetPoint)}
467
- {this._renderArrow(targetPoint, rootElementClientRect)}
468
- </div>
469
- );
470
- }
471
- }
472
-
473
- const styles = StyleSheet.create({
474
- tooltipBubblePositioner: {
475
- // Absolute position.
476
- // `top`/`bottom`/`left`/`right` provided dynamically, and also by the
477
- // additional `tooltipBubblePositioner` classes.
478
- position: "absolute",
479
- zIndex: zindexTooltip,
480
-
481
- // Disable pointer events, so that any extra empty space consumed by
482
- // the FlexiblePosition element is not clickable. In
483
- // `tooltipBubbleWrapper`, we reset this style, so that the user can
484
- // interact with the tooltip content.
485
- pointerEvents: "none",
486
- },
487
-
488
- tooltipAboveModal: {
489
- zIndex: zindexAboveModal,
490
- },
491
-
492
- // More positioner styles for when side="top" or side="bottom".
493
- tooltipBubblePositionerTopBottom: {
494
- // The positioner consumes the full page width, which gives the
495
- // FlexiblePosition element the freedom to position the tooltip
496
- // anywhere horizontally on the page. (Though, of course, it'll aim for
497
- // the target point!)
498
- left: 0,
499
- width: "100%",
500
- },
501
-
502
- // More positioner styles for when side="left" or side="right".
503
- tooltipBubblePositionerLeftRight: {
504
- // The positioner consumes the full page height, which gives the
505
- // FlexiblePosition element the freedom to position the tooltip
506
- // anywhere horizontally on the page. (Though, of course, it'll aim for
507
- // the target point!)
508
- top: 0,
509
- height: "100%",
510
- },
511
-
512
- tooltipBubbleWrapper: {
513
- // `marginLeft` and `marginRight` styles are provided dynamically, to
514
- // implement the `tooltipMargin` provided by the caller.
515
-
516
- // Re-enable pointer events, which were disabled by
517
- // `tooltipBubblePositioner`, so that the user can interact with the
518
- // tooltip content.
519
- pointerEvents: "auto",
520
- },
521
-
522
- tooltipArrowPositioner: {
523
- // `left` and `top`/`bottom` styles are provided dynamically.
524
- position: "absolute",
525
- zIndex: zindexTooltip,
526
-
527
- pointerEvents: "none",
528
- },
529
- });
@@ -1,27 +0,0 @@
1
- // @flow
2
- /**
3
- * Utility functions for the NewTooltip component.
4
- */
5
- import Color from "@khanacademy/wonder-blocks-color";
6
-
7
- import type {NewTooltipProps} from "./types.js";
8
-
9
- /**
10
- * Given a set of NewTooltipProps, return the `backgroundColor` and `textColor`
11
- * of the tooltip. Used in TooltipArrow and TooltipBubble.
12
- */
13
- export function getTooltipColors(tooltipProps: {
14
- color: NewTooltipProps["color"],
15
- inverted: NewTooltipProps["inverted"],
16
- ...
17
- }): {|
18
- backgroundColor: string,
19
- textColor: string,
20
- |} {
21
- const {color, inverted} = tooltipProps;
22
-
23
- if (inverted) {
24
- return {backgroundColor: color, textColor: Color.white};
25
- }
26
- return {backgroundColor: Color.white, textColor: color};
27
- }