@sproutsocial/seeds-react-tooltip 1.1.20 → 1.1.27

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.
package/src/Tooltip.tsx CHANGED
@@ -1,9 +1,11 @@
1
1
  import * as React from "react";
2
- import { useState, useEffect } from "react";
2
+ import { useState, useRef, useEffect, useContext } from "react";
3
3
  import MOTION from "@sproutsocial/seeds-motion/unitless";
4
- import Popout from "@sproutsocial/seeds-react-popout";
4
+ import { Tooltip as BaseTooltip } from "@base-ui/react/tooltip";
5
+ import { DisablePortalToBodyContext } from "@sproutsocial/seeds-react-portal";
6
+ import Box from "@sproutsocial/seeds-react-box";
5
7
  import { StyledTooltipContent } from "./styles";
6
- import type { TypeTooltipProps, TypeTooltipContent } from "./TooltipTypes";
8
+ import type { TypeTooltipProps } from "./TooltipTypes";
7
9
 
8
10
  let idCounter = 0;
9
11
 
@@ -11,51 +13,44 @@ const hasAttribute = (child: React.ReactNode, attribute: string) => {
11
13
  return React.isValidElement(child) && child.props[attribute] !== undefined;
12
14
  };
13
15
 
14
- /** Tooltip Styled Popout wrapper for handling events */
15
- const TooltipBubble = ({
16
- appearance = "pill",
17
- children,
18
- onFocus,
19
- onBlur,
20
- legacyMouseInteraction,
21
- ...rest
22
- }: TypeTooltipContent) => {
23
- // @ts-ignore Will fix during refactor
24
- const handleFocus = (e) => {
25
- onFocus(e);
26
- };
27
- // @ts-ignore Will fix during refactor
28
- const handleBlur = (e) => {
29
- onBlur(e);
30
- };
31
- return (
32
- <StyledTooltipContent
33
- role="tooltip"
34
- appearance={appearance}
35
- borderRadius={appearance === "box" ? 500 : "5000em"}
36
- px={400}
37
- py={appearance === "box" ? 400 : 200}
38
- m={200}
39
- color="text.body"
40
- bg="container.background.base"
41
- boxShadow="medium"
42
- border={500}
43
- borderColor="container.border.base"
44
- {...(legacyMouseInteraction && {
45
- onFocus: handleFocus,
46
- onBlur: handleBlur,
47
- onMouseEnter: handleFocus,
48
- onMouseLeave: handleBlur,
49
- })}
50
- tabIndex={0}
51
- {...rest}
52
- >
53
- {children}
54
- </StyledTooltipContent>
55
- );
56
- };
16
+ type Side = "top" | "bottom" | "left" | "right";
17
+ type Align = "start" | "center" | "end";
18
+
19
+ // `auto`, `auto-start`, and `auto-end` resolve to `side: "top"` (with align
20
+ // taken from the suffix) and rely on base-ui's Positioner collision flipping at
21
+ // runtime, rather than Popper's old best-initial-side selection. Exported for
22
+ // unit testing of this mapping.
23
+ export function mapPlacement(placement: string): { side: Side; align: Align } {
24
+ const parts = placement.split("-");
25
+ let side: Side;
26
+ let align: Align = "center";
27
+
28
+ switch (parts[0]) {
29
+ case "bottom":
30
+ side = "bottom";
31
+ break;
32
+ case "left":
33
+ side = "left";
34
+ break;
35
+ case "right":
36
+ side = "right";
37
+ break;
38
+ case "top":
39
+ case "auto":
40
+ default:
41
+ side = "top";
42
+ break;
43
+ }
44
+
45
+ if (parts[1] === "start") {
46
+ align = "start";
47
+ } else if (parts[1] === "end") {
48
+ align = "end";
49
+ }
50
+
51
+ return { side, align };
52
+ }
57
53
 
58
- /** Core component */
59
54
  const Tooltip = ({
60
55
  content,
61
56
  children,
@@ -66,18 +61,22 @@ const Tooltip = ({
66
61
  zIndex = 7,
67
62
  qa,
68
63
  popoutProps,
64
+ triggerProps,
69
65
  truncated = false,
70
66
  onFocus,
71
67
  onBlur,
72
68
  legacyMouseInteraction,
73
69
  ...rest
74
70
  }: TypeTooltipProps) => {
75
- const [shouldShow, setShouldShow] = useState(false);
76
- const [isOpen, setIsOpen] = useState(false);
71
+ const [open, setOpen] = useState(false);
77
72
  const [id] = useState(`Racine-tooltip-${idCounter++}`);
73
+ const triggerRef = useRef<HTMLSpanElement>(null);
78
74
  const isInvalidContent = content === null || content === undefined;
75
+ const disablePortalToBody = useContext(DisablePortalToBodyContext);
76
+ const [portalContainer, setPortalContainer] = useState<
77
+ HTMLElement | undefined
78
+ >(undefined);
79
79
 
80
- // Compute exitDelay based on legacyMouseInteraction if not explicitly provided
81
80
  const resolvedExitDelay =
82
81
  exitDelay !== undefined
83
82
  ? exitDelay
@@ -85,87 +84,29 @@ const Tooltip = ({
85
84
  ? MOTION.MOTION_DURATION_FAST * 1000
86
85
  : 0;
87
86
 
88
- // @ts-ignore Will fix during refactor
89
- const show = (e) => {
90
- onFocus?.(e);
91
- setShouldShow(true);
92
- };
93
- // @ts-ignore Will fix during refactor
94
- const hide = (e) => {
95
- onBlur?.(e);
96
- setShouldShow(false);
97
- };
98
-
99
87
  const defaultAppearance =
100
88
  appearance || (typeof content === "object" ? "box" : "pill");
101
89
 
102
- /** Handles all the logic around whether to display/not display */
103
- useEffect(() => {
104
- const documentBody = document.body;
105
- let timeout;
106
- const onEsc = (e: KeyboardEvent): void => {
107
- // older browsers use "Esc"
108
- if (["Escape", "Esc"].includes(e.key)) {
109
- setIsOpen(false);
110
- setShouldShow(false);
111
- }
112
- };
113
-
114
- if (shouldShow) {
115
- timeout = setTimeout(() => setIsOpen(true), enterDelay);
116
- } else {
117
- timeout = setTimeout(() => {
118
- setIsOpen(false);
119
- }, resolvedExitDelay);
120
- }
90
+ const { side, align } = mapPlacement(placement);
91
+
92
+ const mergedWrapperProps = {
93
+ ...popoutProps,
94
+ ...triggerProps,
95
+ };
96
+
97
+ const hasWrapperProps = Object.keys(mergedWrapperProps).length > 0;
121
98
 
122
- // We only want listeners from the tooltip if its open in the first place
123
- if (isOpen) {
124
- documentBody.addEventListener("keydown", onEsc, { capture: true });
99
+ useEffect(() => {
100
+ if (disablePortalToBody && triggerRef.current) {
101
+ const dialogContent =
102
+ triggerRef.current.closest<HTMLElement>("[role='dialog']");
103
+ setPortalContainer(dialogContent ?? undefined);
125
104
  }
126
- return () => {
127
- documentBody.removeEventListener("keydown", onEsc, { capture: true });
128
- clearTimeout(timeout);
129
- };
130
- }, [isOpen, setShouldShow, shouldShow, enterDelay, resolvedExitDelay]);
131
-
132
- /** The wrapped content of whats inside the Tooltip */
133
- const TooltipContent = () => (
134
- <TooltipBubble
135
- appearance={defaultAppearance}
136
- onFocus={show}
137
- onBlur={hide}
138
- legacyMouseInteraction={legacyMouseInteraction}
139
- aria-expanded={isOpen}
140
- id={id}
141
- {...rest}
142
- >
143
- {content}
144
- </TooltipBubble>
145
- );
105
+ }, [disablePortalToBody]);
146
106
 
147
- return (
148
- <Popout
149
- content={!isInvalidContent ? TooltipContent : undefined}
150
- isOpen={isOpen}
151
- placement={placement}
152
- qa={{
153
- "data-qa-tooltip": id,
154
- ...qa,
155
- }}
156
- id={id + "-wrapper"}
157
- focusOnContent={false}
158
- zIndex={zIndex}
159
- aria-haspopup="false"
160
- display={truncated ? "flex" : undefined}
161
- disableWrapperAria={true} // required so that the child span doesnt take in redundant aria props
162
- {...popoutProps}
163
- >
107
+ if (isInvalidContent) {
108
+ return (
164
109
  <span
165
- onBlur={hide}
166
- onFocus={show}
167
- onMouseEnter={show}
168
- onMouseLeave={hide}
169
110
  style={
170
111
  truncated
171
112
  ? {
@@ -175,18 +116,90 @@ const Tooltip = ({
175
116
  }
176
117
  : {}
177
118
  }
119
+ >
120
+ {children}
121
+ </span>
122
+ );
123
+ }
124
+
125
+ const tooltipTree = (
126
+ <BaseTooltip.Root
127
+ open={open}
128
+ onOpenChange={setOpen}
129
+ disabled={isInvalidContent}
130
+ disableHoverablePopup={!legacyMouseInteraction}
131
+ >
132
+ <BaseTooltip.Trigger
133
+ delay={enterDelay}
134
+ closeDelay={resolvedExitDelay}
135
+ render={
136
+ <span
137
+ ref={triggerRef}
138
+ onFocus={onFocus}
139
+ onBlur={onBlur}
140
+ data-qa-popout-isopen={open ? "true" : "false"}
141
+ data-qa-tooltip={id}
142
+ {...(qa || {})}
143
+ style={
144
+ truncated
145
+ ? {
146
+ overflow: "hidden",
147
+ textOverflow: "ellipsis",
148
+ whiteSpace: "nowrap",
149
+ display: "inline-flex",
150
+ }
151
+ : undefined
152
+ }
153
+ />
154
+ }
178
155
  >
179
156
  {React.isValidElement(children)
180
157
  ? React.cloneElement(children as React.ReactElement, {
181
- //** There may be cases where the Tooltip's child needs to properly describe its role as expanding a drawer, in which case that property takes priority */
182
158
  "aria-expanded": hasAttribute(children, "aria-expanded")
183
159
  ? children.props["aria-expanded"]
184
160
  : undefined,
185
- "aria-describedby": isOpen ? id : undefined,
161
+ "aria-describedby": open ? id : undefined,
186
162
  })
187
163
  : children}
188
- </span>
189
- </Popout>
164
+ </BaseTooltip.Trigger>
165
+ <BaseTooltip.Portal container={portalContainer}>
166
+ <BaseTooltip.Positioner
167
+ side={side}
168
+ align={align}
169
+ sideOffset={4}
170
+ style={{ zIndex }}
171
+ >
172
+ <BaseTooltip.Popup
173
+ id={id}
174
+ render={
175
+ <StyledTooltipContent
176
+ role="tooltip"
177
+ appearance={defaultAppearance}
178
+ borderRadius={defaultAppearance === "box" ? 500 : "5000em"}
179
+ px={400}
180
+ py={defaultAppearance === "box" ? 400 : 200}
181
+ m={200}
182
+ color="text.body"
183
+ bg="container.background.base"
184
+ boxShadow="medium"
185
+ border={500}
186
+ borderColor="container.border.base"
187
+ tabIndex={legacyMouseInteraction ? 0 : undefined}
188
+ {...rest}
189
+ />
190
+ }
191
+ >
192
+ {content}
193
+ </BaseTooltip.Popup>
194
+ </BaseTooltip.Positioner>
195
+ </BaseTooltip.Portal>
196
+ </BaseTooltip.Root>
197
+ );
198
+
199
+ return hasWrapperProps ? (
200
+ <Box {...mergedWrapperProps}>{tooltipTree}</Box>
201
+ ) : (
202
+ tooltipTree
190
203
  );
191
204
  };
192
205
 
@@ -1,7 +1,23 @@
1
1
  import * as React from "react";
2
- import type { TypePopoutProps } from "@sproutsocial/seeds-react-popout";
3
2
  import type { TypeBoxProps } from "@sproutsocial/seeds-react-box";
4
3
 
4
+ export type TypeTooltipPlacement =
5
+ | "auto"
6
+ | "auto-start"
7
+ | "auto-end"
8
+ | "top"
9
+ | "top-start"
10
+ | "top-end"
11
+ | "bottom"
12
+ | "bottom-start"
13
+ | "bottom-end"
14
+ | "left"
15
+ | "left-start"
16
+ | "left-end"
17
+ | "right"
18
+ | "right-start"
19
+ | "right-end";
20
+
5
21
  export interface TypeTooltipProps
6
22
  extends Omit<
7
23
  TypeBoxProps,
@@ -13,8 +29,14 @@ export interface TypeTooltipProps
13
29
  /** The content to be displayed within the tooltip. If there is no content, just the children are rendered */
14
30
  content: React.ReactNode;
15
31
 
16
- /** The placement of the tooltip in relation to the children */
17
- placement?: TypePopoutProps["placement"];
32
+ /**
33
+ * The placement of the tooltip in relation to the children.
34
+ *
35
+ * Note: `auto`, `auto-start`, and `auto-end` resolve to the `top` side (with
36
+ * the requested alignment) and rely on base-ui's runtime collision flipping,
37
+ * rather than the legacy Popper.js best-initial-side selection.
38
+ */
39
+ placement?: TypeTooltipPlacement;
18
40
 
19
41
  /** The time (in ms) that a user has to be hovered/focused before the tooltip will appear */
20
42
  enterDelay?: number;
@@ -33,8 +55,13 @@ export interface TypeTooltipProps
33
55
  qa?: object;
34
56
  zIndex?: number;
35
57
 
36
- /** Props to be spread onto the underlying Popout component */
37
- popoutProps?: Partial<TypePopoutProps>;
58
+ /**
59
+ * @deprecated Use `triggerProps` instead. The Tooltip no longer uses Popout internally. This prop is accepted and merged with triggerProps
60
+ */
61
+ popoutProps?: Record<string, unknown>;
62
+
63
+ /** Props spread onto an outer wrapper Box rendered around the tooltip. Use this to apply layout styles (e.g. `display`, `width`) or other Box props to the element that wraps the tooltip's children. */
64
+ triggerProps?: TypeBoxProps;
38
65
 
39
66
  /** Truncates text into a single line with ellipsis */
40
67
  truncated?: boolean;
@@ -42,7 +69,6 @@ export interface TypeTooltipProps
42
69
  ariaProps?: Record<string, string>;
43
70
  }
44
71
 
45
- // eslint-disable-next-line @typescript-eslint/no-empty-interface
46
72
  export interface TypeTooltipContent
47
73
  extends Pick<TypeTooltipProps, "appearance" | "children"> {
48
74
  onFocus: (e: React.FocusEvent<HTMLDivElement, FocusEvent>) => void;