@khanacademy/wonder-blocks-tooltip 2.4.2 → 2.4.3

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,265 +0,0 @@
1
- /**
2
- * This component is a light wrapper for react-popper, allowing us to position
3
- * and control the tooltip bubble location and visibility as we need.
4
- */
5
- import * as React from "react";
6
- import {Popper} from "react-popper";
7
- import type {Modifier, PopperChildrenProps} from "react-popper";
8
-
9
- import {UnreachableCaseError} from "@khanacademy/wonder-stuff-core";
10
- import type {ModifierArguments, RootBoundary} from "@popperjs/core";
11
- import type {FlipModifier} from "@popperjs/core/lib/modifiers/flip";
12
- import type {PreventOverflowModifier} from "@popperjs/core/lib/modifiers/preventOverflow";
13
- import type {
14
- Placement,
15
- PopperElementProps,
16
- PopperUpdateFn,
17
- } from "../util/types";
18
- import RefTracker from "../util/ref-tracker";
19
-
20
- type Props = {
21
- /**
22
- * This uses the children-as-a-function approach, mirroring react-popper's
23
- * implementation.
24
- *
25
- * TODO(WB-624): figure out to only allow TooltipBubble and PopoverDialog
26
- */
27
- children: (arg1: PopperElementProps) => React.ReactNode;
28
- /**
29
- * The element that anchors the tooltip bubble.
30
- * This is used to position the bubble.
31
- */
32
- anchorElement?: HTMLElement;
33
- /** Where should the bubble try to go with respect to its anchor. */
34
- placement: Placement;
35
- /**
36
- * Whether the tooltip should automatically update its position when the
37
- * anchor element changes.
38
- */
39
- autoUpdate?: boolean;
40
- /**
41
- * Optional property to set what the root boundary is for the popper behavior.
42
- * This is set to "viewport" by default, causing the popper to be positioned based
43
- * on the user's viewport. If set to "document", it will position itself based
44
- * on where there is available room within the document body.
45
- */
46
- rootBoundary?: RootBoundary;
47
- };
48
-
49
- type DefaultProps = {
50
- rootBoundary: Props["rootBoundary"];
51
- };
52
-
53
- const filterPopperPlacement = (
54
- placement: PopperChildrenProps["placement"],
55
- ): Placement => {
56
- switch (placement) {
57
- case "auto":
58
- case "auto-start":
59
- case "auto-end":
60
- case "top":
61
- case "top-start":
62
- case "top-end":
63
- return "top";
64
- case "bottom":
65
- case "bottom-start":
66
- case "bottom-end":
67
- return "bottom";
68
- case "right":
69
- case "right-start":
70
- case "right-end":
71
- return "right";
72
- case "left":
73
- case "left-start":
74
- case "left-end":
75
- return "left";
76
- default:
77
- throw new UnreachableCaseError(placement);
78
- }
79
- };
80
-
81
- type SmallViewportModifier = Modifier<"smallViewport", Record<string, never>>;
82
-
83
- type Modifiers =
84
- | Partial<PreventOverflowModifier>
85
- | Partial<FlipModifier>
86
- | Partial<SmallViewportModifier>;
87
-
88
- /**
89
- * This function calculates the height of the popper
90
- * vs. the height of the viewport. If the popper is larger
91
- * than the viewport, it sets the popper isReferenceHidden
92
- * state to false, to ensure the popper stays visible even if
93
- * the reference is no longer in view. If the popper is less
94
- * than the viewport, it leaves it as is so the popper will
95
- * disappear if the reference is no longer in view.
96
- */
97
- function _modifyPosition({
98
- state,
99
- }: ModifierArguments<Record<string, never>>): void {
100
- // Calculates the available space for the popper based on the placement
101
- // relative to the viewport.
102
- const popperHeight =
103
- state.rects.popper.height + state.rects.reference.height;
104
- const minHeight = document.documentElement.clientHeight;
105
-
106
- if (minHeight < popperHeight && state.modifiersData.hide) {
107
- state.modifiersData.hide = {
108
- ...state.modifiersData.hide,
109
- isReferenceHidden: false,
110
- };
111
- }
112
- }
113
-
114
- const smallViewportModifier: SmallViewportModifier = {
115
- name: "smallViewport",
116
- enabled: true,
117
- phase: "main",
118
- fn: _modifyPosition,
119
- };
120
-
121
- /**
122
- * A component that wraps react-popper's Popper component to provide a
123
- * consistent interface for positioning floating elements.
124
- */
125
- export default class TooltipPopper extends React.Component<Props> {
126
- static defaultProps: DefaultProps = {
127
- rootBoundary: "viewport",
128
- };
129
-
130
- /**
131
- * Automatically updates the position of the floating element when necessary
132
- * to ensure it stays anchored.
133
- *
134
- * NOTE: This is a temporary solution that checks for changes in the anchor
135
- * element's DOM. This is not a perfect solution and may not work in all
136
- * cases. It is recommended to use the autoUpdate prop only when necessary.
137
- *
138
- * TODO(WB-1680): Replace this with floating-ui's autoUpdate feature.
139
- * @see https://floating-ui.com/docs/autoupdate
140
- */
141
- componentDidMount() {
142
- const {anchorElement, autoUpdate} = this.props;
143
- if (!anchorElement || !autoUpdate) {
144
- return;
145
- }
146
-
147
- this._observer = new MutationObserver(() => {
148
- // Update the popper when the anchor element changes.
149
- this._popperUpdate?.();
150
- });
151
-
152
- // Check for DOM changes to the anchor element.
153
- this._observer.observe(anchorElement, {
154
- attributes: true,
155
- childList: true,
156
- subtree: true,
157
- });
158
- }
159
-
160
- componentWillUnmount() {
161
- this._observer?.disconnect();
162
- }
163
-
164
- /**
165
- * A ref tracker for the bubble element.
166
- */
167
- _bubbleRefTracker: RefTracker = new RefTracker();
168
- /**
169
- * A ref tracker for the tail element.
170
- */
171
- _tailRefTracker: RefTracker = new RefTracker();
172
- /**
173
- * A MutationObserver that watches for changes to the anchor element.
174
- */
175
- _observer: MutationObserver | null = null;
176
- /**
177
- * A function that can be called to update the popper.
178
- * @see https://popper.js.org/docs/v2/lifecycle/#manual-update
179
- */
180
- _popperUpdate: PopperUpdateFn | null = null;
181
-
182
- _renderPositionedContent(
183
- popperProps: PopperChildrenProps,
184
- ): React.ReactNode {
185
- const {children} = this.props;
186
-
187
- // We'll hide some complexity from the children here and ensure
188
- // that our placement always has a value.
189
- const placement: Placement =
190
- filterPopperPlacement(popperProps.placement) ||
191
- this.props.placement;
192
-
193
- // Just in case the callbacks have changed, let's update our reference
194
- // trackers.
195
- this._bubbleRefTracker.setCallback(popperProps.ref);
196
- this._tailRefTracker.setCallback(popperProps.arrowProps.ref);
197
-
198
- // Store a reference to the update function so that we can call it
199
- // later if needed.
200
- this._popperUpdate = popperProps.update;
201
-
202
- // Here we translate from the react-popper's PropperChildrenProps
203
- // to our own TooltipBubbleProps.
204
- const bubbleProps = {
205
- placement,
206
- style: {
207
- // NOTE(jeresig): We can't just use `popperProps.style` here
208
- // as the TypeScript type doesn't match Aphrodite's CSS TypeScript
209
- // props (as it doesn't camelCase props). So we just copy over the
210
- // props that we need, instead.
211
- top: popperProps.style.top,
212
- left: popperProps.style.left,
213
- bottom: popperProps.style.bottom,
214
- right: popperProps.style.right,
215
- position: popperProps.style.position,
216
- transform: popperProps.style.transform,
217
- },
218
- updateBubbleRef: this._bubbleRefTracker.updateRef,
219
- tailOffset: {
220
- bottom: popperProps.arrowProps.style.bottom,
221
- right: popperProps.arrowProps.style.right,
222
- top: popperProps.arrowProps.style.top,
223
- left: popperProps.arrowProps.style.left,
224
- transform: popperProps.arrowProps.style.transform,
225
- },
226
- updateTailRef: this._tailRefTracker.updateRef,
227
- isReferenceHidden: popperProps.isReferenceHidden,
228
- } as const;
229
- return children(bubbleProps);
230
- }
231
-
232
- render(): React.ReactNode {
233
- const {anchorElement, placement, rootBoundary} = this.props;
234
-
235
- // TODO(WB-1680): Use floating-ui's
236
- const modifiers: Modifiers[] = [smallViewportModifier];
237
-
238
- if (rootBoundary === "viewport") {
239
- modifiers.push({
240
- name: "preventOverflow",
241
- options: {
242
- rootBoundary: "viewport",
243
- },
244
- });
245
- } else {
246
- modifiers.push({
247
- name: "flip",
248
- options: {
249
- rootBoundary: "document",
250
- },
251
- });
252
- }
253
-
254
- return (
255
- <Popper
256
- referenceElement={anchorElement}
257
- strategy="fixed"
258
- placement={placement}
259
- modifiers={modifiers}
260
- >
261
- {(props) => this._renderPositionedContent(props)}
262
- </Popper>
263
- );
264
- }
265
- }
@@ -1,445 +0,0 @@
1
- import * as React from "react";
2
- import {css, StyleSheet} from "aphrodite";
3
-
4
- import {View} from "@khanacademy/wonder-blocks-core";
5
- import {color, spacing} from "@khanacademy/wonder-blocks-tokens";
6
- import {Strut} from "@khanacademy/wonder-blocks-layout";
7
-
8
- import type {StyleType} from "@khanacademy/wonder-blocks-core";
9
- import type {getRefFn, Placement, Offset} from "../util/types";
10
-
11
- export type Props = {
12
- /**
13
- * Whether we should use the default white background color or switch to a
14
- * different bg color.
15
- *
16
- * NOTE: Added to support custom popovers
17
- * @ignore
18
- */
19
- color: keyof typeof color;
20
- /** The offset of the tail indicating where it should be positioned. */
21
- offset?: Offset;
22
- /** The placement of the tail with respect to the tooltip anchor. */
23
- placement: Placement;
24
- /** A callback to update the ref of the tail element. */
25
- updateRef?: getRefFn;
26
- /** When true, the tail is shown; otherwise, it is not but it still takes
27
- * space in the layout. */
28
- show: boolean;
29
- };
30
-
31
- type DefaultProps = {
32
- color: Props["color"];
33
- show: Props["show"];
34
- };
35
-
36
- type Dimensions = {
37
- trimlinePoints: [string, string];
38
- points: [string, string, string];
39
- height: number;
40
- width: number;
41
- };
42
-
43
- type FilterPosition = {
44
- y: string;
45
- x: string;
46
- offsetShadowX: number;
47
- };
48
-
49
- // TODO(somewhatabstract): Replace this really basic unique ID work with
50
- // something SSR-friendly and more robust.
51
- let tempIdCounter = 0;
52
-
53
- export default class TooltipTail extends React.Component<Props> {
54
- static defaultProps: DefaultProps = {
55
- color: "white",
56
- show: true,
57
- };
58
-
59
- _calculateDimensionsFromPlacement(): Dimensions {
60
- const {placement} = this.props;
61
-
62
- // The trimline, which we draw to make the tail flush to the bubble,
63
- // has a thickness of 1. Since the line is drawn centered to the
64
- // coordinates, we use an offset of 0.5 so that it properly covers what
65
- // we want it to.
66
- const trimlineOffset = 0.5;
67
-
68
- // Calculate the three points of the arrow. Depending on the tail's
69
- // direction (i.e., the tooltip's "side"), we choose different points,
70
- // and set our SVG's bounds differently.
71
- //
72
- // Note that when the tail points to the left or right, the width/height
73
- // are inverted.
74
- switch (placement) {
75
- case "top":
76
- return {
77
- trimlinePoints: [
78
- `0,-${trimlineOffset}`,
79
- `${ARROW_WIDTH},-${trimlineOffset}`,
80
- ],
81
- points: [
82
- "0,0",
83
- `${ARROW_WIDTH / 2},${ARROW_HEIGHT}`,
84
- `${ARROW_WIDTH},0`,
85
- ],
86
- height: ARROW_HEIGHT,
87
- width: ARROW_WIDTH,
88
- };
89
-
90
- case "right":
91
- return {
92
- trimlinePoints: [
93
- `${ARROW_HEIGHT + trimlineOffset},0`,
94
- `${ARROW_HEIGHT + trimlineOffset},${ARROW_WIDTH}`,
95
- ],
96
- points: [
97
- `${ARROW_HEIGHT},0`,
98
- `0,${ARROW_WIDTH / 2}`,
99
- `${ARROW_HEIGHT},${ARROW_WIDTH}`,
100
- ],
101
- width: ARROW_HEIGHT,
102
- height: ARROW_WIDTH,
103
- };
104
-
105
- case "bottom":
106
- return {
107
- trimlinePoints: [
108
- `0, ${ARROW_HEIGHT + trimlineOffset}`,
109
- `${ARROW_WIDTH},${ARROW_HEIGHT + trimlineOffset}`,
110
- ],
111
- points: [
112
- `0, ${ARROW_HEIGHT}`,
113
- `${ARROW_WIDTH / 2},0`,
114
- `${ARROW_WIDTH},${ARROW_HEIGHT}`,
115
- ],
116
- width: ARROW_WIDTH,
117
- height: ARROW_HEIGHT,
118
- };
119
-
120
- case "left":
121
- return {
122
- trimlinePoints: [
123
- `-${trimlineOffset},0`,
124
- `-${trimlineOffset},${ARROW_WIDTH}`,
125
- ],
126
- points: [
127
- `0,0`,
128
- `${ARROW_HEIGHT},${ARROW_WIDTH / 2}`,
129
- `0,${ARROW_WIDTH}`,
130
- ],
131
- width: ARROW_HEIGHT,
132
- height: ARROW_WIDTH,
133
- };
134
-
135
- default:
136
- throw new Error(`Unknown placement: ${placement}`);
137
- }
138
- }
139
-
140
- _getFilterPositioning(): FilterPosition | null | undefined {
141
- const {placement} = this.props;
142
- switch (placement) {
143
- case "top":
144
- return {
145
- y: "-50%",
146
- x: "-50%",
147
- offsetShadowX: 0,
148
- };
149
-
150
- case "bottom":
151
- // No shadow on the arrow as it falls "under" the bubble.
152
- return null;
153
-
154
- case "left":
155
- return {
156
- y: "-50%",
157
- x: "0%",
158
- offsetShadowX: 1,
159
- };
160
-
161
- case "right":
162
- return {
163
- y: "-50%",
164
- x: "-100%",
165
- offsetShadowX: -1,
166
- };
167
-
168
- default:
169
- throw new Error(`Unknown placement: ${placement}`);
170
- }
171
- }
172
-
173
- /**
174
- * Create an SVG filter that applies a blur to an element.
175
- * We'll apply it to a dark shape outlining the tooltip, which
176
- * will produce the overall effect of a drop-shadow.
177
- *
178
- * Also, scope its ID by side, so that tooltips with other
179
- * "side" values don't end up using the wrong filter from
180
- * elsewhere in the document. (The `height` value depends on
181
- * which way the arrow is turned!)
182
- */
183
- _maybeRenderDropshadow(points: [string, string, string]): React.ReactNode {
184
- const position = this._getFilterPositioning();
185
- if (!position) {
186
- return null;
187
- }
188
- const {placement} = this.props;
189
- const {y, x, offsetShadowX} = position;
190
- const dropShadowFilterId = `tooltip-dropshadow-${placement}-${tempIdCounter++}`;
191
- return [
192
- <filter
193
- key="filter"
194
- id={dropShadowFilterId}
195
- // Height and width tell the filter how big of a canvas to
196
- // draw based on its parent size. i.e. 2 times bigger.
197
- // This is so that the diffuse gaussian blur has space to
198
- // bleed into.
199
- width="200%"
200
- height="200%"
201
- // The x and y values tell the filter where, relative to its
202
- // parent, it should begin showing its canvas. Without these
203
- // the filter would clip at 0,0, which would look really
204
- // strange.
205
- x={x}
206
- y={y}
207
- >
208
- {/* Here we provide a nice blur that will be our shadow
209
- * The stdDeviation is the spread of the blur. We don't want it
210
- * too diffuse.
211
- */}
212
- <feGaussianBlur
213
- in="SourceAlpha"
214
- stdDeviation={spacing.xxSmall_6 / 2}
215
- />
216
-
217
- {/* Here we adjust the alpha (feFuncA) linearly so as to blend
218
- * the shadow to match the rest of the tooltip bubble shadow.
219
- * It is a combination of the diffuse blur and this alpha
220
- * value that will make it look right.
221
- *
222
- * The value of 0.3. was arrived at from trial and error.
223
- */}
224
- <feComponentTransfer>
225
- <feFuncA type="linear" slope="0.3" />
226
- </feComponentTransfer>
227
- </filter>,
228
- /**
229
- * Draw the tooltip arrow and apply the blur filter we created
230
- * above, to produce a drop shadow effect.
231
- * We move it down a bit with a translation, so that it is what
232
- * we want.
233
- *
234
- * We offset the shadow on the X-axis because for left/right
235
- * tails, we move the tail 1px toward the bubble. If we didn't
236
- * offset the shadow, it would crash the bubble outline.
237
- *
238
- * See styles below for why we offset the arrow.
239
- */
240
- <g key="dropshadow" transform={`translate(${offsetShadowX},5.5)`}>
241
- <polyline
242
- fill={color.offBlack16}
243
- points={points.join(" ")}
244
- stroke={color.offBlack32}
245
- filter={`url(#${dropShadowFilterId})`}
246
- />
247
- </g>,
248
- ];
249
- }
250
-
251
- _getFullTailWidth(): number {
252
- return ARROW_WIDTH + 2 * MIN_DISTANCE_FROM_CORNERS;
253
- }
254
-
255
- _getFullTailHeight(): number {
256
- return ARROW_HEIGHT + DISTANCE_FROM_ANCHOR;
257
- }
258
-
259
- _getContainerStyle(): StyleType {
260
- const {placement} = this.props;
261
- /**
262
- * Ensure the container is sized properly for us to be placed correctly
263
- * by the Popper.js code.
264
- *
265
- * Here we offset the arrow 1px toward the bubble. This ensures the arrow
266
- * outline meets the bubble outline and allows the arrow to erase the bubble
267
- * outline between the ends of the arrow outline. We do this so that the
268
- * arrow outline and bubble outline create a single, seamless outline of
269
- * the callout.
270
- *
271
- * NOTE: The widths and heights refer to the downward-pointing tail
272
- * (i.e. placement="top"). When the tail points to the left or right
273
- * instead, the width/height are inverted.
274
- */
275
- const fullTailWidth = this._getFullTailWidth();
276
- const fullTailHeight = this._getFullTailHeight();
277
-
278
- switch (placement) {
279
- case "top":
280
- return {
281
- top: -1,
282
- width: fullTailWidth,
283
- height: fullTailHeight,
284
- };
285
-
286
- case "right":
287
- return {
288
- left: 1,
289
- width: fullTailHeight,
290
- height: fullTailWidth,
291
- };
292
-
293
- case "bottom":
294
- return {
295
- top: 1,
296
- width: fullTailWidth,
297
- height: fullTailHeight,
298
- };
299
-
300
- case "left":
301
- return {
302
- left: -1,
303
- width: fullTailHeight,
304
- height: fullTailWidth,
305
- };
306
-
307
- default:
308
- throw new Error(`Unknown placement: ${placement}`);
309
- }
310
- }
311
-
312
- _getArrowStyle(): React.CSSProperties {
313
- const {placement} = this.props;
314
- switch (placement) {
315
- case "top":
316
- return {
317
- marginLeft: MIN_DISTANCE_FROM_CORNERS,
318
- marginRight: MIN_DISTANCE_FROM_CORNERS,
319
- paddingBottom: DISTANCE_FROM_ANCHOR,
320
- };
321
-
322
- case "right":
323
- return {
324
- marginTop: MIN_DISTANCE_FROM_CORNERS,
325
- marginBottom: MIN_DISTANCE_FROM_CORNERS,
326
- paddingLeft: DISTANCE_FROM_ANCHOR,
327
- };
328
-
329
- case "bottom":
330
- return {
331
- marginLeft: MIN_DISTANCE_FROM_CORNERS,
332
- marginRight: MIN_DISTANCE_FROM_CORNERS,
333
- paddingTop: DISTANCE_FROM_ANCHOR,
334
- };
335
-
336
- case "left":
337
- return {
338
- marginTop: MIN_DISTANCE_FROM_CORNERS,
339
- marginBottom: MIN_DISTANCE_FROM_CORNERS,
340
- paddingRight: DISTANCE_FROM_ANCHOR,
341
- };
342
-
343
- default:
344
- throw new Error(`Unknown placement: ${placement}`);
345
- }
346
- }
347
-
348
- _renderArrow(): React.ReactNode {
349
- const {trimlinePoints, points, height, width} =
350
- this._calculateDimensionsFromPlacement();
351
-
352
- const {color: arrowColor, show} = this.props;
353
-
354
- if (!show) {
355
- // If we aren't showing the tail, we still need to take up space
356
- // so we render a strut instead.
357
- return <Strut size={height} />;
358
- }
359
-
360
- return (
361
- <svg
362
- className={css(styles.arrow)}
363
- style={this._getArrowStyle()}
364
- width={width}
365
- height={height}
366
- aria-hidden
367
- >
368
- {this._maybeRenderDropshadow(points)}
369
- {/**
370
- * Draw the actual background of the tooltip arrow.
371
- *
372
- * We draw the outline in white too so that when we draw the
373
- * outline, it draws over white and not the dropshadow behind.
374
- */}
375
- <polyline
376
- fill={color[arrowColor]}
377
- stroke={color[arrowColor]}
378
- points={points.join(" ")}
379
- />
380
- {/* Draw the tooltip outline around the tooltip arrow. */}
381
- <polyline
382
- // Redraw the stroke on top of the background color,
383
- // so that the ends aren't extra dark where they meet
384
- // the border of the tooltip.
385
- fill={color[arrowColor]}
386
- points={points.join(" ")}
387
- stroke={color.offBlack16}
388
- />
389
- {/* Draw a trimline to make the arrow appear flush */}
390
- <polyline
391
- stroke={color[arrowColor]}
392
- points={trimlinePoints.join(" ")}
393
- />
394
- </svg>
395
- );
396
- }
397
-
398
- render(): React.ReactNode {
399
- const {offset, placement, updateRef} = this.props;
400
- return (
401
- <View
402
- style={[
403
- styles.tailContainer,
404
- {...offset},
405
- this._getContainerStyle(),
406
- ]}
407
- data-placement={placement}
408
- ref={updateRef}
409
- >
410
- {this._renderArrow()}
411
- </View>
412
- );
413
- }
414
- }
415
-
416
- /**
417
- * Some constants to make style generation easier to understand.
418
- * NOTE: The widths and heights refer to the downward-pointing tail
419
- * (i.e. placement="top"). When the tail points to the left or right instead,
420
- * the width/height are inverted.
421
- */
422
- const DISTANCE_FROM_ANCHOR = spacing.xSmall_8;
423
-
424
- const MIN_DISTANCE_FROM_CORNERS = spacing.xSmall_8;
425
-
426
- const ARROW_WIDTH = spacing.large_24;
427
- const ARROW_HEIGHT = spacing.small_12;
428
-
429
- const styles = StyleSheet.create({
430
- /**
431
- * Container
432
- */
433
- tailContainer: {
434
- position: "relative",
435
- pointerEvents: "none",
436
- },
437
-
438
- /**
439
- * Arrow
440
- */
441
- arrow: {
442
- // Ensure the dropshadow bleeds outside our bounds.
443
- overflow: "visible",
444
- },
445
- });