All checks were successful
Build and Release / build-and-release (push) Successful in 13m12s
- 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.
255 lines
8.0 KiB
TypeScript
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,
|
|
)}
|
|
</>
|
|
);
|
|
}
|