feat(shell): styled hover/focus tooltips on standalone sidebar icons (#3995)

This commit is contained in:
Cyrus Gray
2026-06-23 21:25:48 +05:30
committed by GitHub
parent 0c94699e5e
commit 66adce7f8f
7 changed files with 490 additions and 138 deletions
@@ -6,6 +6,7 @@ import { useT } from '../../../lib/i18n/I18nContext';
import { trackEvent } from '../../../services/analytics';
import { useAppSelector } from '../../../store/hooks';
import { selectUnreadCount } from '../../../store/notificationSlice';
import { Tooltip } from '../../ui';
import { NavIcon } from './navIcons';
import { useHomeNav } from './useHomeNav';
@@ -58,63 +59,65 @@ export default function CollapsedNavRail() {
return (
<nav className="flex flex-col items-center gap-0.5" aria-label={t('nav.home')}>
{/* Home */}
<button
type="button"
onClick={handleHome}
title={t('nav.home')}
aria-label={t('nav.home')}
aria-current={homeActive ? 'page' : undefined}
className={`${RAIL_BTN} ${
homeActive
? 'bg-white text-stone-900 shadow-sm dark:bg-neutral-800 dark:text-neutral-100'
: 'text-stone-500 hover:bg-stone-100 hover:text-stone-700 dark:text-neutral-400 dark:hover:bg-neutral-800/60 dark:hover:text-neutral-200'
}`}>
<NavIcon id="home" className="h-4 w-4" />
</button>
<Tooltip label={t('nav.home')}>
<button
type="button"
onClick={handleHome}
aria-label={t('nav.home')}
aria-current={homeActive ? 'page' : undefined}
className={`${RAIL_BTN} ${
homeActive
? 'bg-white text-stone-900 shadow-sm dark:bg-neutral-800 dark:text-neutral-100'
: 'text-stone-500 hover:bg-stone-100 hover:text-stone-700 dark:text-neutral-400 dark:hover:bg-neutral-800/60 dark:hover:text-neutral-200'
}`}>
<NavIcon id="home" className="h-4 w-4" />
</button>
</Tooltip>
{/* Wallet shortcut — mirrors SidebarHeader wallet button for collapsed state. */}
<button
type="button"
onClick={() => navigate('/settings/wallet-balances')}
title={t('nav.wallet')}
aria-label={t('nav.wallet')}
aria-current={
matchActive('/settings/wallet-balances', location.pathname) ? 'page' : undefined
}
data-analytics-id="collapsed-rail-wallet"
className={`${RAIL_BTN} ${
matchActive('/settings/wallet-balances', location.pathname)
? 'bg-white text-stone-900 shadow-sm dark:bg-neutral-800 dark:text-neutral-100'
: 'text-stone-500 hover:bg-stone-100 hover:text-stone-700 dark:text-neutral-400 dark:hover:bg-neutral-800/60 dark:hover:text-neutral-200'
}`}>
<NavIcon id="wallet" className="h-4 w-4" />
</button>
<Tooltip label={t('nav.wallet')}>
<button
type="button"
onClick={() => navigate('/settings/wallet-balances')}
aria-label={t('nav.wallet')}
aria-current={
matchActive('/settings/wallet-balances', location.pathname) ? 'page' : undefined
}
data-analytics-id="collapsed-rail-wallet"
className={`${RAIL_BTN} ${
matchActive('/settings/wallet-balances', location.pathname)
? 'bg-white text-stone-900 shadow-sm dark:bg-neutral-800 dark:text-neutral-100'
: 'text-stone-500 hover:bg-stone-100 hover:text-stone-700 dark:text-neutral-400 dark:hover:bg-neutral-800/60 dark:hover:text-neutral-200'
}`}>
<NavIcon id="wallet" className="h-4 w-4" />
</button>
</Tooltip>
{/* Primary nav destinations */}
{tabs.map(tab => {
const active = matchActive(tab.path, location.pathname);
const showBadge = tab.id === 'notifications' && unreadCount > 0;
return (
<button
key={tab.id}
type="button"
data-walkthrough={tab.walkthroughAttr}
onClick={() => handleClick(tab, active)}
title={tab.label}
aria-label={tab.label}
aria-current={active ? 'page' : undefined}
className={`${RAIL_BTN} ${
active
? 'bg-white text-stone-900 shadow-sm dark:bg-neutral-800 dark:text-neutral-100'
: 'text-stone-500 hover:bg-stone-100 hover:text-stone-700 dark:text-neutral-400 dark:hover:bg-neutral-800/60 dark:hover:text-neutral-200'
}`}>
<NavIcon id={tab.id} className="h-4 w-4" />
{showBadge && (
<span className="absolute -right-0.5 -top-0.5 flex h-[13px] min-w-[13px] items-center justify-center rounded-full bg-coral-500 px-1 text-[9px] font-bold leading-none text-white">
{unreadCount > 9 ? '9+' : unreadCount}
</span>
)}
</button>
<Tooltip key={tab.id} label={tab.label}>
<button
type="button"
data-walkthrough={tab.walkthroughAttr}
onClick={() => handleClick(tab, active)}
aria-label={tab.label}
aria-current={active ? 'page' : undefined}
className={`${RAIL_BTN} ${
active
? 'bg-white text-stone-900 shadow-sm dark:bg-neutral-800 dark:text-neutral-100'
: 'text-stone-500 hover:bg-stone-100 hover:text-stone-700 dark:text-neutral-400 dark:hover:bg-neutral-800/60 dark:hover:text-neutral-200'
}`}>
<NavIcon id={tab.id} className="h-4 w-4" />
{showBadge && (
<span className="absolute -right-0.5 -top-0.5 flex h-[13px] min-w-[13px] items-center justify-center rounded-full bg-coral-500 px-1 text-[9px] font-bold leading-none text-white">
{unreadCount > 9 ? '9+' : unreadCount}
</span>
)}
</button>
</Tooltip>
);
})}
@@ -9,6 +9,7 @@ import {
setSidebarWidth,
toggleSidebar,
} from '../../../store/layoutSlice';
import { Tooltip } from '../../ui';
import CollapsedNavRail from './CollapsedNavRail';
// `app-shell` (not the older `root-shell`) so the persisted geometry seeds
@@ -178,23 +179,24 @@ export default function RootShellLayout({ sidebar, children }: RootShellLayoutPr
above the HTML layer — starts to its right and never covers it. */}
{!isOpen && (
<div className="flex w-9 flex-none flex-col items-center gap-0.5 border-r border-stone-200 bg-white pt-2 dark:border-neutral-800 dark:bg-neutral-900">
<button
type="button"
onClick={() => dispatch(setSidebarVisible({ id: LAYOUT_ID, visible: true }))}
data-testid="root-shell-reopen"
data-analytics-id="root-shell-reopen-sidebar"
aria-label={t('layout.showSidebar')}
title={t('layout.showSidebar')}
className="flex h-8 w-8 items-center justify-center rounded-lg text-stone-500 transition-colors hover:bg-stone-100 hover:text-stone-700 dark:text-neutral-400 dark:hover:bg-neutral-800/60 dark:hover:text-neutral-200">
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={1.8}
d="M9 5l7 7-7 7"
/>
</svg>
</button>
<Tooltip label={t('layout.showSidebar')}>
<button
type="button"
onClick={() => dispatch(setSidebarVisible({ id: LAYOUT_ID, visible: true }))}
data-testid="root-shell-reopen"
data-analytics-id="root-shell-reopen-sidebar"
aria-label={t('layout.showSidebar')}
className="flex h-8 w-8 items-center justify-center rounded-lg text-stone-500 transition-colors hover:bg-stone-100 hover:text-stone-700 dark:text-neutral-400 dark:hover:bg-neutral-800/60 dark:hover:text-neutral-200">
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={1.8}
d="M9 5l7 7-7 7"
/>
</svg>
</button>
</Tooltip>
{/* Keep the primary nav reachable while collapsed: an icon-only rail. */}
<div className="mt-1 w-full border-t border-stone-200/70 pt-1 dark:border-neutral-800/70">
<CollapsedNavRail />
@@ -46,6 +46,8 @@ describe('SidebarHeader', () => {
renderWithProviders(<SidebarHeader />, { initialEntries: ['/home'] });
const btn = screen.getByRole('button', { name: 'nav.wallet' });
expect(btn).toHaveAttribute('aria-label', 'nav.wallet');
// The styled <Tooltip> wrapper re-applies a native `title` fallback so the
// label still surfaces if the portal pill is occluded by a CEF webview.
expect(btn).toHaveAttribute('title', 'nav.wallet');
});
@@ -1,6 +1,7 @@
import { useNavigate } from 'react-router-dom';
import { useT } from '../../../lib/i18n/I18nContext';
import { Tooltip } from '../../ui';
import { useRootSidebar } from './RootShellLayout';
import { useHomeNav } from './useHomeNav';
@@ -8,8 +9,9 @@ const ICON_BTN =
'flex h-7 w-7 flex-none items-center justify-center rounded-md text-stone-500 transition-colors hover:bg-stone-100 hover:text-stone-700 dark:text-neutral-400 dark:hover:bg-neutral-800/60 dark:hover:text-neutral-200';
/**
* Thin utility header at the top of the root sidebar: jump Home, open Settings,
* and collapse the sidebar. Language is chosen from Settings, not here.
* Thin utility header at the top of the root sidebar: jump Home, open the
* wallet, open Settings, and collapse the sidebar. Language is chosen from
* Settings, not here.
*/
export default function SidebarHeader() {
const { t } = useT();
@@ -19,82 +21,86 @@ export default function SidebarHeader() {
return (
<div className="flex items-center justify-between gap-1 px-2 py-1.5">
<button
type="button"
onClick={handleHome}
className={ICON_BTN}
data-analytics-id="sidebar-header-home"
aria-label={t('nav.home')}
title={t('nav.home')}>
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={1.8}
d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-4 0a2 2 0 01-2-2v-4a2 2 0 012-2h2a2 2 0 012 2v4a2 2 0 01-2 2h-2z"
/>
</svg>
</button>
<Tooltip label={t('nav.home')}>
<button
type="button"
onClick={handleHome}
className={ICON_BTN}
data-analytics-id="sidebar-header-home"
aria-label={t('nav.home')}>
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={1.8}
d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-4 0a2 2 0 01-2-2v-4a2 2 0 012-2h2a2 2 0 012 2v4a2 2 0 01-2 2h-2z"
/>
</svg>
</button>
</Tooltip>
<div className="flex items-center gap-0.5">
{/* Wallet shortcut — one-click access to wallet balances. */}
<button
type="button"
onClick={() => navigate('/settings/wallet-balances')}
className={ICON_BTN}
data-analytics-id="sidebar-header-wallet"
aria-label={t('nav.wallet')}
title={t('nav.wallet')}>
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={1.8}
d="M3 10h18M7 15h1m4 0h1m-7 4h12a3 3 0 003-3V8a3 3 0 00-3-3H6a3 3 0 00-3 3v8a3 3 0 003 3z"
/>
</svg>
</button>
<Tooltip label={t('nav.wallet')}>
<button
type="button"
onClick={() => navigate('/settings/wallet-balances')}
className={ICON_BTN}
data-analytics-id="sidebar-header-wallet"
aria-label={t('nav.wallet')}>
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={1.8}
d="M3 10h18M7 15h1m4 0h1m-7 4h12a3 3 0 003-3V8a3 3 0 00-3-3H6a3 3 0 00-3 3v8a3 3 0 003 3z"
/>
</svg>
</button>
</Tooltip>
<button
type="button"
onClick={() => navigate('/settings')}
className={ICON_BTN}
data-analytics-id="sidebar-header-settings"
aria-label={t('nav.settings')}
title={t('nav.settings')}>
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={1.8}
d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"
/>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={1.8}
d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"
/>
</svg>
</button>
<Tooltip label={t('nav.settings')}>
<button
type="button"
onClick={() => navigate('/settings')}
className={ICON_BTN}
data-analytics-id="sidebar-header-settings"
aria-label={t('nav.settings')}>
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={1.8}
d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"
/>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={1.8}
d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"
/>
</svg>
</button>
</Tooltip>
{/* Collapse the sidebar — sits on the right, next to Settings. */}
<button
type="button"
onClick={hide}
className={ICON_BTN}
data-analytics-id="sidebar-header-collapse"
aria-label={t('chat.hideSidebar')}
title={t('chat.hideSidebar')}>
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={1.8}
d="M15 19l-7-7 7-7M20 5v14"
/>
</svg>
</button>
<Tooltip label={t('chat.hideSidebar')}>
<button
type="button"
onClick={hide}
className={ICON_BTN}
data-analytics-id="sidebar-header-collapse"
aria-label={t('chat.hideSidebar')}>
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={1.8}
d="M15 19l-7-7 7-7M20 5v14"
/>
</svg>
</button>
</Tooltip>
</div>
</div>
);
+175
View File
@@ -0,0 +1,175 @@
import { act, fireEvent, render, screen } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import Tooltip from './Tooltip';
describe('Tooltip', () => {
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.runOnlyPendingTimers();
vi.useRealTimers();
});
it('does not show the pill until the trigger is hovered past the delay', () => {
render(
<Tooltip label="Wallet" delayMs={300}>
<button type="button" aria-label="Wallet">
icon
</button>
</Tooltip>
);
expect(screen.queryByTestId('tooltip')).toBeNull();
fireEvent.mouseEnter(screen.getByRole('button', { name: 'Wallet' }));
// Before the dwell elapses, nothing is shown.
act(() => {
vi.advanceTimersByTime(200);
});
expect(screen.queryByTestId('tooltip')).toBeNull();
act(() => {
vi.advanceTimersByTime(100);
});
expect(screen.getByTestId('tooltip')).toHaveTextContent('Wallet');
});
it('hides the pill on mouse leave and cancels a pending show', () => {
render(
<Tooltip label="Settings">
<button type="button" aria-label="Settings">
icon
</button>
</Tooltip>
);
const btn = screen.getByRole('button', { name: 'Settings' });
fireEvent.mouseEnter(btn);
fireEvent.mouseLeave(btn);
act(() => {
vi.advanceTimersByTime(500);
});
// Leaving before the delay cancels the show entirely.
expect(screen.queryByTestId('tooltip')).toBeNull();
});
it('shows on keyboard focus and hides on blur', () => {
render(
<Tooltip label="Home" delayMs={0}>
<button type="button" aria-label="Home">
icon
</button>
</Tooltip>
);
const btn = screen.getByRole('button', { name: 'Home' });
fireEvent.focus(btn);
act(() => {
vi.advanceTimersByTime(0);
});
expect(screen.getByTestId('tooltip')).toHaveTextContent('Home');
fireEvent.blur(btn);
expect(screen.queryByTestId('tooltip')).toBeNull();
});
it('preserves the triggers own handlers', () => {
const onMouseEnter = vi.fn();
const onClick = vi.fn();
render(
<Tooltip label="Chat" delayMs={0}>
<button type="button" aria-label="Chat" onMouseEnter={onMouseEnter} onClick={onClick}>
icon
</button>
</Tooltip>
);
const btn = screen.getByRole('button', { name: 'Chat' });
fireEvent.mouseEnter(btn);
fireEvent.click(btn);
expect(onMouseEnter).toHaveBeenCalledTimes(1);
expect(onClick).toHaveBeenCalledTimes(1);
});
it('marks the pill aria-hidden so screen readers use the trigger label', () => {
render(
<Tooltip label="Connections" delayMs={0}>
<button type="button" aria-label="Connections">
icon
</button>
</Tooltip>
);
fireEvent.mouseEnter(screen.getByRole('button', { name: 'Connections' }));
act(() => {
vi.advanceTimersByTime(0);
});
expect(screen.getByTestId('tooltip')).toHaveAttribute('aria-hidden', 'true');
});
it.each([
['right', 'translateY(-50%)'],
['left', 'translate(-100%, -50%)'],
['top', 'translate(-50%, -100%)'],
['bottom', 'translate(-50%, 0)'],
] as const)('positions the pill against the %s edge', (side, transform) => {
render(
<Tooltip label="Brain" side={side} delayMs={0}>
<button type="button" aria-label="Brain">
icon
</button>
</Tooltip>
);
fireEvent.mouseEnter(screen.getByRole('button', { name: 'Brain' }));
act(() => {
vi.advanceTimersByTime(0);
});
expect(screen.getByTestId('tooltip')).toHaveStyle({ transform });
});
it('renders an invalid (non-element) child as-is without a pill', () => {
render(<Tooltip label="Noop">{'just text' as never}</Tooltip>);
expect(screen.getByText('just text')).toBeInTheDocument();
expect(screen.queryByTestId('tooltip')).toBeNull();
});
it('applies a native title fallback on the trigger (CEF-occlusion safety net)', () => {
render(
<Tooltip label="Wallet" delayMs={0}>
<button type="button" aria-label="Wallet">
icon
</button>
</Tooltip>
);
expect(screen.getByRole('button', { name: 'Wallet' })).toHaveAttribute('title', 'Wallet');
});
it('respects a trigger-supplied title over the label fallback', () => {
render(
<Tooltip label="Wallet" delayMs={0}>
<button type="button" aria-label="Wallet" title="Custom">
icon
</button>
</Tooltip>
);
expect(screen.getByRole('button', { name: 'Wallet' })).toHaveAttribute('title', 'Custom');
});
it('clears a pending show when the trigger unmounts mid-hover', () => {
const { unmount } = render(
<Tooltip label="Human" delayMs={300}>
<button type="button" aria-label="Human">
icon
</button>
</Tooltip>
);
fireEvent.mouseEnter(screen.getByRole('button', { name: 'Human' }));
unmount();
// The timer must not fire setState on the unmounted component.
act(() => {
vi.advanceTimersByTime(500);
});
expect(screen.queryByTestId('tooltip')).toBeNull();
});
});
+161
View File
@@ -0,0 +1,161 @@
import debug from 'debug';
import {
cloneElement,
type FocusEvent,
isValidElement,
type MouseEvent,
type ReactElement,
useCallback,
useEffect,
useRef,
useState,
} from 'react';
import { createPortal } from 'react-dom';
const log = debug('ui:tooltip');
/** Which edge of the trigger the tooltip floats from. Sidebar icons use `right`. */
export type TooltipSide = 'right' | 'top' | 'bottom' | 'left';
export interface TooltipProps {
/** Short, minimal label — e.g. "Wallet", "Settings". */
label: string;
/** Single focusable trigger (a button/anchor). Its hover/focus drives the tip. */
children: ReactElement;
/** Edge to float from. Defaults to `right` (best for a vertical sidebar rail). */
side?: TooltipSide;
/** Hover/focus dwell before showing, in ms. Keeps the tip from flickering. */
delayMs?: number;
}
/** Gap in px between the trigger and the tooltip pill. */
const GAP = 8;
interface Anchor {
top: number;
left: number;
side: TooltipSide;
}
function anchorFor(rect: DOMRect, side: TooltipSide): Anchor {
switch (side) {
case 'top':
return { top: rect.top - GAP, left: rect.left + rect.width / 2, side };
case 'bottom':
return { top: rect.bottom + GAP, left: rect.left + rect.width / 2, side };
case 'left':
return { top: rect.top + rect.height / 2, left: rect.left - GAP, side };
case 'right':
default:
return { top: rect.top + rect.height / 2, left: rect.right + GAP, side };
}
}
/** Maps the float edge to the transform that pins the pill against that edge. */
const TRANSFORM: Record<TooltipSide, string> = {
right: 'translateY(-50%)',
left: 'translate(-100%, -50%)',
top: 'translate(-50%, -100%)',
bottom: 'translate(-50%, 0)',
};
/**
* Lightweight, dependency-free hover/focus tooltip for icon-only controls.
*
* Renders a styled pill into a body portal (so it escapes the sidebar's
* `overflow` clipping) positioned from the trigger's bounding rect. The pill
* gives fast (~`delayMs`), on-brand feedback that the native `title` attribute
* (lags ~1.5s, unstyled, easy to miss) cannot.
*
* The trigger also keeps a native `title={label}` fallback (unless it already
* sets one). This is deliberate: the pill lives in the HTML layer, but account
* webviews are native CEF views composited *above* HTML (see `Accounts.tsx` /
* `RootShellLayout.tsx`), so a pill that lands over an active webview is painted
* behind it. The OS-drawn `title` renders above everything and guarantees a
* label survives in that case. Pair with an `aria-label` on the trigger for
* screen readers (it takes precedence over `title`, so there's no double
* announcement); the pill itself is decorative (`aria-hidden`).
*/
export default function Tooltip({ label, children, side = 'right', delayMs = 300 }: TooltipProps) {
const [anchor, setAnchor] = useState<Anchor | null>(null);
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const clearTimer = useCallback(() => {
if (timerRef.current) {
clearTimeout(timerRef.current);
timerRef.current = null;
}
}, []);
const show = useCallback(
(rect: DOMRect) => {
clearTimer();
timerRef.current = setTimeout(() => {
log('show', label);
setAnchor(anchorFor(rect, side));
}, delayMs);
},
[clearTimer, delayMs, label, side]
);
const hide = useCallback(() => {
clearTimer();
setAnchor(null);
}, [clearTimer]);
// Drop any pending show if the trigger unmounts mid-hover (e.g. navigation
// tears down the rail before mouseleave/blur fires).
useEffect(() => clearTimer, [clearTimer]);
if (!isValidElement(children)) {
log('children is not a valid element; rendering trigger as-is');
return children;
}
const triggerProps = children.props as {
title?: string;
onMouseEnter?: (e: MouseEvent<HTMLElement>) => void;
onMouseLeave?: (e: MouseEvent<HTMLElement>) => void;
onFocus?: (e: FocusEvent<HTMLElement>) => void;
onBlur?: (e: FocusEvent<HTMLElement>) => void;
};
const trigger = cloneElement(children, {
// Native `title` fallback for when the portal pill is occluded by a native
// CEF webview composited above the HTML layer. A trigger-supplied title wins.
title: triggerProps.title ?? label,
onMouseEnter: (e: MouseEvent<HTMLElement>) => {
show(e.currentTarget.getBoundingClientRect());
triggerProps.onMouseEnter?.(e);
},
onMouseLeave: (e: MouseEvent<HTMLElement>) => {
hide();
triggerProps.onMouseLeave?.(e);
},
onFocus: (e: FocusEvent<HTMLElement>) => {
show(e.currentTarget.getBoundingClientRect());
triggerProps.onFocus?.(e);
},
onBlur: (e: FocusEvent<HTMLElement>) => {
hide();
triggerProps.onBlur?.(e);
},
} as Partial<typeof children.props>);
return (
<>
{trigger}
{anchor &&
createPortal(
<div
data-testid="tooltip"
aria-hidden="true"
className="pointer-events-none fixed z-[9999] whitespace-nowrap rounded-md bg-stone-800 px-2 py-1 text-xs font-medium text-white shadow-md animate-fade-in dark:bg-neutral-700"
style={{ top: anchor.top, left: anchor.left, transform: TRANSFORM[anchor.side] }}>
{label}
</div>,
document.body
)}
</>
);
}
+3
View File
@@ -7,6 +7,9 @@ export type { CardProps, CardVariant, CardPadding } from './Card';
export { default as Input } from './Input';
export type { InputProps, InputSize } from './Input';
export { default as Tooltip } from './Tooltip';
export type { TooltipProps, TooltipSide } from './Tooltip';
export { Spinner, CheckIcon, CloseIcon, WarningIcon } from './icons';
export { CenteredLoadingState, ErrorBanner, InlineLoadingStatus } from './LoadingState';
export { ModalShell } from './ModalShell';