feat(ui): add Button, Modal, Spinner, Toast, and Tooltip components with styles
All checks were successful
Build and Release / build-and-release (push) Successful in 13m12s
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.
This commit is contained in:
254
packages/ui/src/Tooltip.tsx
Normal file
254
packages/ui/src/Tooltip.tsx
Normal file
@@ -0,0 +1,254 @@
|
||||
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,
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user