Files
DiscordClone/packages/ui/src/Tooltip.tsx
Bryan1029384756 b7a4cf4ce8
All checks were successful
Build and Release / build-and-release (push) Successful in 13m12s
feat(ui): add Button, Modal, Spinner, Toast, and Tooltip components with styles
- Implemented Button component with various props for customization.
- Created Modal component with header, content, and footer subcomponents.
- Added Spinner component for loading indicators.
- Developed Toast component for displaying notifications.
- Introduced Tooltip component for contextual hints with keyboard shortcuts.
- Added corresponding CSS modules for styling each component.
- Updated index file to export new components.
- Configured TypeScript settings for the UI package.
2026-04-14 09:02:14 -05:00

255 lines
8.0 KiB
TypeScript

import { AnimatePresence, motion } from 'framer-motion';
import { type ReactNode, cloneElement, isValidElement, useRef, useState, useCallback, useLayoutEffect } from 'react';
import { createPortal } from 'react-dom';
import styles from './Tooltip.module.css';
export interface TooltipProps {
content: ReactNode;
placement?: 'top' | 'bottom' | 'left' | 'right';
children: ReactNode;
delay?: number;
/**
* Optional keyboard shortcut rendered as a row of keycaps under
* the main label. Accepts a lowercase `+`-joined combo string
* like `"ctrl+shift+m"` (the same format the KeybindStore uses).
* Empty / undefined hides the row entirely.
*/
shortcut?: string;
/**
* When true the tooltip stays open while the cursor is over the
* floating panel (not just the trigger), and clicks on the panel
* bubble through to `onContentClick`. Used for reaction chips
* where the tooltip doubles as a "click to view details" target.
*/
interactive?: boolean;
/**
* Fired when the user clicks the floating content. Only hooked
* up when `interactive` is true.
*/
onContentClick?: () => void;
}
/** Split a KeybindStore-format combo into display tokens and label
* each one so the Tooltip can render them as individual keycaps. */
function parseShortcut(combo: string): string[] {
if (!combo) return [];
return combo
.split('+')
.map((t) => t.trim())
.filter(Boolean)
.map(formatShortcutToken);
}
function formatShortcutToken(token: string): string {
// KeybindContext emits combos like `Ctrl+Shift+M` or `Ctrl+,` — so
// normalize the incoming token to lowercase for switch matching,
// but keep the original around for the default branch so
// punctuation and single letters round-trip as uppercase keycaps.
const lower = token.toLowerCase();
switch (lower) {
case 'ctrl':
case 'control':
return 'CTRL';
case 'shift':
return '⇧';
case 'alt':
case 'option':
return 'ALT';
case 'meta':
case 'cmd':
case 'command':
return '⌘';
case 'arrowup':
return '↑';
case 'arrowdown':
return '↓';
case 'arrowleft':
return '←';
case 'arrowright':
return '→';
case 'escape':
return 'ESC';
case 'enter':
return '↵';
case 'tab':
return 'TAB';
case ' ':
case 'space':
return 'SPACE';
default:
return token.length === 1 ? token.toUpperCase() : token.toUpperCase();
}
}
export function Tooltip({
content,
placement = 'top',
children,
delay = 300,
shortcut,
interactive = false,
onContentClick,
}: TooltipProps) {
const [isOpen, setIsOpen] = useState(false);
const referenceRef = useRef<HTMLElement | null>(null);
const floatingRef = useRef<HTMLDivElement | null>(null);
const openTimeoutRef = useRef<ReturnType<typeof setTimeout>>(undefined);
// Close timer — separate from the open timer so we can cancel a
// pending close when the cursor enters the floating panel.
const closeTimeoutRef = useRef<ReturnType<typeof setTimeout>>(undefined);
const [pos, setPos] = useState<React.CSSProperties>({ position: 'fixed', visibility: 'hidden' });
/**
* Callback ref used when cloning the trigger. Stores the DOM node
* AND forwards it to whatever ref the caller already had on the
* child — without this forwarding the Tooltip would overwrite
* refs like `pinsButtonRef` and break any `getBoundingClientRect`
* logic on the consumer side.
*/
const makeSetRef = useCallback(
(childRef: any) => (node: HTMLElement | null) => {
referenceRef.current = node;
if (!childRef) return;
if (typeof childRef === 'function') {
childRef(node);
} else if (typeof childRef === 'object' && 'current' in childRef) {
(childRef as { current: HTMLElement | null }).current = node;
}
},
[],
);
// Compute position after the tooltip mounts and the reference is visible
useLayoutEffect(() => {
if (!isOpen || !referenceRef.current) return;
const computePos = () => {
const el = referenceRef.current;
const floating = floatingRef.current;
if (!el) return;
const rect = el.getBoundingClientRect();
const floatingRect = floating?.getBoundingClientRect();
const fw = floatingRect?.width || 0;
const fh = floatingRect?.height || 0;
const style: React.CSSProperties = { position: 'fixed', zIndex: 20000 };
if (placement === 'top') {
style.left = rect.left + rect.width / 2 - fw / 2;
style.top = rect.top - fh - 8;
} else if (placement === 'bottom') {
style.left = rect.left + rect.width / 2 - fw / 2;
style.top = rect.bottom + 8;
} else if (placement === 'left') {
style.left = rect.left - fw - 8;
style.top = rect.top + rect.height / 2 - fh / 2;
} else {
style.left = rect.right + 8;
style.top = rect.top + rect.height / 2 - fh / 2;
}
setPos(style);
};
// First render: measure floating element then position
requestAnimationFrame(computePos);
}, [isOpen, placement]);
const handleMouseEnter = useCallback(() => {
if (closeTimeoutRef.current) clearTimeout(closeTimeoutRef.current);
if (openTimeoutRef.current) clearTimeout(openTimeoutRef.current);
openTimeoutRef.current = setTimeout(() => setIsOpen(true), delay);
}, [delay]);
const handleMouseLeave = useCallback(() => {
if (openTimeoutRef.current) clearTimeout(openTimeoutRef.current);
// Interactive mode: give the user a short grace window to
// move from the trigger onto the floating panel. Non-
// interactive tooltips still close instantly to match the
// classic label behaviour.
if (interactive) {
if (closeTimeoutRef.current) clearTimeout(closeTimeoutRef.current);
closeTimeoutRef.current = setTimeout(() => setIsOpen(false), 150);
} else {
setIsOpen(false);
}
}, [interactive]);
const handleFloatingEnter = useCallback(() => {
if (!interactive) return;
if (closeTimeoutRef.current) clearTimeout(closeTimeoutRef.current);
}, [interactive]);
const handleFloatingLeave = useCallback(() => {
if (!interactive) return;
if (closeTimeoutRef.current) clearTimeout(closeTimeoutRef.current);
closeTimeoutRef.current = setTimeout(() => setIsOpen(false), 150);
}, [interactive]);
return (
<>
{isValidElement(children) &&
cloneElement(children as React.ReactElement<any>, {
ref: makeSetRef((children as any).ref),
onMouseEnter: handleMouseEnter,
onMouseLeave: handleMouseLeave,
onFocus: handleMouseEnter,
onBlur: handleMouseLeave,
})}
{createPortal(
<AnimatePresence>
{isOpen && (
<motion.div
ref={floatingRef}
style={{
...pos,
// Interactive tooltips must accept pointer events so
// the user can hover onto them and click them. The
// default tooltip is non-interactive and leaves the
// CSS default alone.
pointerEvents: interactive ? 'auto' : undefined,
cursor: interactive && onContentClick ? 'pointer' : undefined,
// Interactive tooltips often carry richer content
// (emoji glyph, multi-line body) — give them a wider
// clamp than the default 220px label limit.
maxWidth: interactive ? 280 : undefined,
}}
className={styles.tooltip}
initial={{ opacity: 0, scale: 0.95 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.95 }}
transition={{ duration: 0.1 }}
role={interactive && onContentClick ? 'button' : 'tooltip'}
onMouseEnter={handleFloatingEnter}
onMouseLeave={handleFloatingLeave}
onClick={
interactive && onContentClick
? () => {
if (closeTimeoutRef.current)
clearTimeout(closeTimeoutRef.current);
setIsOpen(false);
onContentClick();
}
: undefined
}
>
<div className={styles.content}>{content}</div>
{shortcut && (
<div className={styles.shortcutRow}>
{parseShortcut(shortcut).map((token, i) => (
<span key={`${i}-${token}`} className={styles.keycap}>
{token}
</span>
))}
</div>
)}
</motion.div>
)}
</AnimatePresence>,
document.body,
)}
</>
);
}