Skip to content

Commit 0d36fbc

Browse files
authored
Merge pull request #455 from contentstack/VE-6600-Hover-Toolbar-Support
[Feature] HoverToolbar
2 parents cf73f0b + da638dd commit 0d36fbc

18 files changed

Lines changed: 1065 additions & 376 deletions

.talismanrc

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,4 +10,6 @@ fileignoreconfig:
1010
checksum: 3badd6a142456b6a361569e6fc546349a38ac6b366bef7fd5255d1e93220444e
1111
- filename: src/visualBuilder/components/Collab/ThreadPopup/__test__/CommentTextArea.test.tsx
1212
checksum: d0ef271ee5381d9feab06bda6e7e89bd0a882fee87495627bd811c1f0a5459c7
13+
- filename: package-lock.json
14+
checksum: fd06363871d0ee16ebfb5d9d0cc479e0922a615bb76584b80bb6933ee6c3e237
1315
version: "1.0"

package-lock.json

Lines changed: 23 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,7 @@
8686
"url": "https://github.com/contentstack/live-preview-sdk.git"
8787
},
8888
"dependencies": {
89+
"@floating-ui/dom": "^1.7.2",
8990
"@preact/compat": "17.1.2",
9091
"@preact/signals": "1.2.2",
9192
"classnames": "^2.5.1",

src/__test__/utils.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@ export async function sleep(waitTimeInMs = 100): Promise<void> {
3939
export const waitForHoverOutline = async () => {
4040
await waitFor(() => {
4141
const hoverOutline = document.querySelector(
42-
"[data-testid='visual-builder__hover-outline']"
42+
"[data-testid='visual-builder__hover-outline'][style]"
4343
);
4444
expect(hoverOutline).not.toBeNull();
4545
});
Lines changed: 184 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,184 @@
1+
import { h, cloneElement } from 'preact';
2+
import { useState, useEffect, useRef } from 'preact/hooks';
3+
import {
4+
computePosition,
5+
flip,
6+
shift,
7+
offset,
8+
arrow
9+
} from '@floating-ui/dom';
10+
import { visualBuilderStyles } from '../visualBuilder.style';
11+
import classNames from 'classnames';
12+
import { ContentTypeIcon } from './icons';
13+
import { FieldTypeIconsMap } from '../generators/generateCustomCursor';
14+
interface TooltipProps {
15+
children: JSX.Element;
16+
content: JSX.Element;
17+
placement?: 'top-start' | 'bottom-start' | 'left-start' | 'right-start';
18+
}
19+
20+
/**
21+
* A lightweight, reusable tooltip component for Preact powered by Floating UI.
22+
*
23+
* @param {object} props - The component props.
24+
* @param {preact.ComponentChildren} props.children - The single child element that triggers the tooltip.
25+
* @param {string | preact.VNode} props.content - The content to display inside the tooltip.
26+
* @param {'top'|'bottom'|'left'|'right'} [props.placement='top'] - The desired placement of the tooltip.
27+
*/
28+
const Tooltip = ({ children, content, placement = 'top-start' }: TooltipProps) => {
29+
const [isVisible, setIsVisible] = useState(false);
30+
// Create refs for the trigger and the floating tooltip elements
31+
const triggerRef = useRef(null);
32+
const tooltipRef = useRef(null);
33+
const arrowRef = useRef(null);
34+
35+
const showTooltip = () => setIsVisible(true);
36+
const hideTooltip = () => setIsVisible(false);
37+
38+
// This effect calculates the tooltip's position whenever it becomes visible
39+
// or if its content or placement changes.
40+
useEffect(() => {
41+
if (!isVisible || !triggerRef.current || !tooltipRef.current) {
42+
return;
43+
}
44+
45+
const trigger = triggerRef.current as HTMLElement;
46+
const tooltip = tooltipRef.current as HTMLElement;
47+
48+
computePosition(trigger, tooltip, {
49+
placement,
50+
// Middleware runs in order to modify the position
51+
middleware: [
52+
offset(8), // Add 8px of space between the trigger and tooltip
53+
flip(), // Flip to the opposite side if it overflows
54+
shift({ padding: 5 }), // Shift to keep it in view
55+
...(arrowRef.current ? [arrow({ element: arrowRef.current as HTMLElement })] : []), // Handle arrow positioning
56+
],
57+
}).then(({ x, y, placement, middlewareData }) => {
58+
// Apply the calculated coordinates to the tooltip element
59+
Object.assign(tooltip.style, {
60+
left: `${x}px`,
61+
top: `${y}px`,
62+
});
63+
64+
// Position the arrow element
65+
if (middlewareData.arrow && arrowRef.current) {
66+
const { x: arrowX, y: arrowY } = middlewareData.arrow;
67+
const side = placement.split('-')[0];
68+
const staticSide = {
69+
top: 'bottom',
70+
right: 'left',
71+
bottom: 'top',
72+
left: 'right',
73+
}[side] as string;
74+
75+
const arrowElement = arrowRef.current as HTMLElement;
76+
77+
// Reset all positioning properties
78+
Object.assign(arrowElement.style, {
79+
left: '',
80+
top: '',
81+
right: '',
82+
bottom: '',
83+
});
84+
85+
// For placements like top-start, bottom-start, etc., we want the arrow
86+
// to be centered on the tooltip rather than pointing at the trigger center
87+
if (placement.includes('-start') || placement.includes('-end')) {
88+
const tooltipRect = tooltip.getBoundingClientRect();
89+
90+
if (side === 'top' || side === 'bottom') {
91+
// For top/bottom placements, center the arrow horizontally
92+
arrowElement.style.left = `${14}px`; // 4px = half arrow width
93+
if (arrowY != null) {
94+
arrowElement.style.top = `${arrowY}px`;
95+
}
96+
} else {
97+
// For left/right placements, center the arrow vertically
98+
arrowElement.style.top = `${tooltipRect.height / 2 - 4}px`; // 4px = half arrow height
99+
if (arrowX != null) {
100+
arrowElement.style.left = `${arrowX}px`;
101+
}
102+
}
103+
} else {
104+
// For regular placements (top, bottom, left, right), use floating-ui's positioning
105+
if (arrowX != null) {
106+
arrowElement.style.left = `${arrowX}px`;
107+
}
108+
if (arrowY != null) {
109+
arrowElement.style.top = `${arrowY}px`;
110+
}
111+
}
112+
113+
// Position arrow to overlap the tooltip's border
114+
(arrowElement.style as any)[staticSide] = '-4px';
115+
}
116+
});
117+
118+
}, [isVisible, placement, content]);
119+
120+
// We need to clone the child element to attach our ref and event listeners.
121+
// This ensures we don't wrap the child in an extra <div>.
122+
const triggerWithListeners = cloneElement(children, {
123+
ref: triggerRef,
124+
onMouseEnter: showTooltip,
125+
onMouseLeave: hideTooltip,
126+
onFocus: showTooltip,
127+
onBlur: hideTooltip,
128+
'aria-describedby': 'lightweight-tooltip' // for accessibility
129+
});
130+
131+
return (
132+
<>
133+
{triggerWithListeners}
134+
{isVisible && (
135+
<div
136+
ref={tooltipRef}
137+
role="tooltip"
138+
id="lightweight-tooltip"
139+
className={classNames("tooltip-container", visualBuilderStyles()["tooltip-container"])}
140+
>
141+
{content}
142+
<div ref={arrowRef} className={classNames("tooltip-arrow", visualBuilderStyles()["tooltip-arrow"])}></div>
143+
</div>
144+
)}
145+
</>
146+
);
147+
};
148+
149+
function ToolbarTooltipContent({contentTypeName, referenceFieldName}: {contentTypeName: string, referenceFieldName: string}) {
150+
return (
151+
<div className={classNames("toolbar-tooltip-content", visualBuilderStyles()["toolbar-tooltip-content"])}>
152+
{
153+
referenceFieldName && (
154+
<div className={classNames("toolbar-tooltip-content-item", visualBuilderStyles()["toolbar-tooltip-content-item"])}>
155+
<div dangerouslySetInnerHTML={{__html: FieldTypeIconsMap.reference}} className={classNames("visual-builder__field-icon", visualBuilderStyles()["visual-builder__field-icon"])}/>
156+
<p>{referenceFieldName}</p>
157+
</div>
158+
)
159+
}
160+
{
161+
contentTypeName && (
162+
<div className={classNames("toolbar-tooltip-content-item", visualBuilderStyles()["toolbar-tooltip-content-item"])}>
163+
<ContentTypeIcon />
164+
<p>{contentTypeName}</p>
165+
</div>
166+
)
167+
}
168+
</div>
169+
)
170+
}
171+
172+
export function ToolbarTooltip({children, data, disabled = false}: {children: JSX.Element, data: {contentTypeName: string, referenceFieldName: string}, disabled?: boolean}) {
173+
if (disabled) {
174+
return children;
175+
}
176+
const { contentTypeName, referenceFieldName } = data;
177+
return (
178+
<Tooltip content={<ToolbarTooltipContent contentTypeName={contentTypeName} referenceFieldName={referenceFieldName} />}>
179+
{children}
180+
</Tooltip>
181+
)
182+
}
183+
184+
export default Tooltip;

0 commit comments

Comments
 (0)