Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .talismanrc
Original file line number Diff line number Diff line change
Expand Up @@ -10,4 +10,6 @@ fileignoreconfig:
checksum: 3badd6a142456b6a361569e6fc546349a38ac6b366bef7fd5255d1e93220444e
- filename: src/visualBuilder/components/Collab/ThreadPopup/__test__/CommentTextArea.test.tsx
checksum: d0ef271ee5381d9feab06bda6e7e89bd0a882fee87495627bd811c1f0a5459c7
- filename: package-lock.json
checksum: fd06363871d0ee16ebfb5d9d0cc479e0922a615bb76584b80bb6933ee6c3e237
version: "1.0"
23 changes: 23 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,7 @@
"url": "https://github.com/contentstack/live-preview-sdk.git"
},
"dependencies": {
"@floating-ui/dom": "^1.7.2",
"@preact/compat": "17.1.2",
"@preact/signals": "1.2.2",
"classnames": "^2.5.1",
Expand Down
2 changes: 1 addition & 1 deletion src/__test__/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ export async function sleep(waitTimeInMs = 100): Promise<void> {
export const waitForHoverOutline = async () => {
await waitFor(() => {
const hoverOutline = document.querySelector(
"[data-testid='visual-builder__hover-outline']"
"[data-testid='visual-builder__hover-outline'][style]"
);
expect(hoverOutline).not.toBeNull();
});
Expand Down
184 changes: 184 additions & 0 deletions src/visualBuilder/components/Tooltip.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,184 @@
import { h, cloneElement } from 'preact';
import { useState, useEffect, useRef } from 'preact/hooks';
import {
computePosition,
flip,
shift,
offset,
arrow
} from '@floating-ui/dom';
import { visualBuilderStyles } from '../visualBuilder.style';
import classNames from 'classnames';
import { ContentTypeIcon } from './icons';
import { FieldTypeIconsMap } from '../generators/generateCustomCursor';
interface TooltipProps {
children: JSX.Element;
content: JSX.Element;
placement?: 'top-start' | 'bottom-start' | 'left-start' | 'right-start';
}

/**
* A lightweight, reusable tooltip component for Preact powered by Floating UI.
*
* @param {object} props - The component props.
* @param {preact.ComponentChildren} props.children - The single child element that triggers the tooltip.
* @param {string | preact.VNode} props.content - The content to display inside the tooltip.
* @param {'top'|'bottom'|'left'|'right'} [props.placement='top'] - The desired placement of the tooltip.
*/
const Tooltip = ({ children, content, placement = 'top-start' }: TooltipProps) => {
const [isVisible, setIsVisible] = useState(false);
// Create refs for the trigger and the floating tooltip elements
const triggerRef = useRef(null);
const tooltipRef = useRef(null);
const arrowRef = useRef(null);

const showTooltip = () => setIsVisible(true);
const hideTooltip = () => setIsVisible(false);

// This effect calculates the tooltip's position whenever it becomes visible
// or if its content or placement changes.
useEffect(() => {
if (!isVisible || !triggerRef.current || !tooltipRef.current) {
return;
}

const trigger = triggerRef.current as HTMLElement;
const tooltip = tooltipRef.current as HTMLElement;

computePosition(trigger, tooltip, {
placement,
// Middleware runs in order to modify the position
middleware: [
offset(8), // Add 8px of space between the trigger and tooltip
flip(), // Flip to the opposite side if it overflows
shift({ padding: 5 }), // Shift to keep it in view
...(arrowRef.current ? [arrow({ element: arrowRef.current as HTMLElement })] : []), // Handle arrow positioning
],
}).then(({ x, y, placement, middlewareData }) => {
// Apply the calculated coordinates to the tooltip element
Object.assign(tooltip.style, {
left: `${x}px`,
top: `${y}px`,
});

// Position the arrow element
if (middlewareData.arrow && arrowRef.current) {
const { x: arrowX, y: arrowY } = middlewareData.arrow;
const side = placement.split('-')[0];
const staticSide = {
top: 'bottom',
right: 'left',
bottom: 'top',
left: 'right',
}[side] as string;

const arrowElement = arrowRef.current as HTMLElement;

// Reset all positioning properties
Object.assign(arrowElement.style, {
left: '',
top: '',
right: '',
bottom: '',
});

// For placements like top-start, bottom-start, etc., we want the arrow
// to be centered on the tooltip rather than pointing at the trigger center
if (placement.includes('-start') || placement.includes('-end')) {
const tooltipRect = tooltip.getBoundingClientRect();

if (side === 'top' || side === 'bottom') {
// For top/bottom placements, center the arrow horizontally
arrowElement.style.left = `${14}px`; // 4px = half arrow width
if (arrowY != null) {
arrowElement.style.top = `${arrowY}px`;
}
} else {
// For left/right placements, center the arrow vertically
arrowElement.style.top = `${tooltipRect.height / 2 - 4}px`; // 4px = half arrow height
if (arrowX != null) {
arrowElement.style.left = `${arrowX}px`;
}
}
} else {
// For regular placements (top, bottom, left, right), use floating-ui's positioning
if (arrowX != null) {
arrowElement.style.left = `${arrowX}px`;
}
if (arrowY != null) {
arrowElement.style.top = `${arrowY}px`;
}
}

// Position arrow to overlap the tooltip's border
(arrowElement.style as any)[staticSide] = '-4px';
}
});

}, [isVisible, placement, content]);

// We need to clone the child element to attach our ref and event listeners.
// This ensures we don't wrap the child in an extra <div>.
const triggerWithListeners = cloneElement(children, {
ref: triggerRef,
onMouseEnter: showTooltip,
onMouseLeave: hideTooltip,
onFocus: showTooltip,
onBlur: hideTooltip,
'aria-describedby': 'lightweight-tooltip' // for accessibility
});

return (
<>
{triggerWithListeners}
{isVisible && (
<div
ref={tooltipRef}
role="tooltip"
id="lightweight-tooltip"
className={classNames("tooltip-container", visualBuilderStyles()["tooltip-container"])}
>
{content}
<div ref={arrowRef} className={classNames("tooltip-arrow", visualBuilderStyles()["tooltip-arrow"])}></div>
</div>
)}
</>
);
};

function ToolbarTooltipContent({contentTypeName, referenceFieldName}: {contentTypeName: string, referenceFieldName: string}) {
return (
<div className={classNames("toolbar-tooltip-content", visualBuilderStyles()["toolbar-tooltip-content"])}>
{
referenceFieldName && (
<div className={classNames("toolbar-tooltip-content-item", visualBuilderStyles()["toolbar-tooltip-content-item"])}>
<div dangerouslySetInnerHTML={{__html: FieldTypeIconsMap.reference}} className={classNames("visual-builder__field-icon", visualBuilderStyles()["visual-builder__field-icon"])}/>
<p>{referenceFieldName}</p>
</div>
)
}
{
contentTypeName && (
<div className={classNames("toolbar-tooltip-content-item", visualBuilderStyles()["toolbar-tooltip-content-item"])}>
<ContentTypeIcon />
<p>{contentTypeName}</p>
</div>
)
}
</div>
)
}

export function ToolbarTooltip({children, data, disabled = false}: {children: JSX.Element, data: {contentTypeName: string, referenceFieldName: string}, disabled?: boolean}) {
if (disabled) {
return children;
}
const { contentTypeName, referenceFieldName } = data;
return (
<Tooltip content={<ToolbarTooltipContent contentTypeName={contentTypeName} referenceFieldName={referenceFieldName} />}>
{children}
</Tooltip>
)
}

export default Tooltip;
Loading
Loading