mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-27 21:08:00 +00:00
feat(shell): styled hover/focus tooltips on standalone sidebar icons (#3995)
This commit is contained in:
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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 trigger’s 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();
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
Reference in New Issue
Block a user