Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions react/src/components/COMPONENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,3 +44,21 @@ provided the badge becomes a keyboard-focusable interactive control
<Badge variant="success" size="sm">Verified</Badge>
<Badge variant="warning" onClick={() => alert('clicked')}>Dismiss</Badge>
```

## Tooltip

Shows contextual text on hover and keyboard focus, dismissible with Escape.
Renders `role="tooltip"` and links the trigger via `aria-describedby`. Wraps a
single focusable element.

| Prop | Type | Default | Description |
| ----------- | ----------------------------------------- | ------- | ----------------------------- |
| `content` | `React.ReactNode` | (none) | Tooltip contents. |
| `children` | `React.ReactElement` | (none) | The focusable trigger. |
| `placement` | `'top' \| 'bottom' \| 'left' \| 'right'` | `'top'` | Position relative to trigger. |

```tsx
<Tooltip content="Copy address">
<button onClick={copy}>Copy</button>
</Tooltip>
```
41 changes: 41 additions & 0 deletions react/src/components/Tooltip.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import '@testing-library/jest-dom';
import React from 'react';
import { render, screen, fireEvent } from '@testing-library/react';

import { Tooltip } from './Tooltip';

describe('Tooltip', () => {
it('is hidden until hovered, then shows with role=tooltip and links the trigger', () => {
const { container } = render(
<Tooltip content="Help text">
<button>trigger</button>
</Tooltip>,
);
const wrapper = container.firstChild as HTMLElement;
expect(screen.queryByRole('tooltip')).not.toBeInTheDocument();

fireEvent.mouseEnter(wrapper);
const tip = screen.getByRole('tooltip');
expect(tip).toHaveTextContent('Help text');
expect(screen.getByRole('button', { name: 'trigger' })).toHaveAttribute(
'aria-describedby',
tip.id,
);

fireEvent.mouseLeave(wrapper);
expect(screen.queryByRole('tooltip')).not.toBeInTheDocument();
});

it('dismisses on Escape', () => {
const { container } = render(
<Tooltip content="Help text">
<button>trigger</button>
</Tooltip>,
);
const wrapper = container.firstChild as HTMLElement;
fireEvent.mouseEnter(wrapper);
expect(screen.getByRole('tooltip')).toBeInTheDocument();
fireEvent.keyDown(wrapper, { key: 'Escape' });
expect(screen.queryByRole('tooltip')).not.toBeInTheDocument();
});
});
54 changes: 54 additions & 0 deletions react/src/components/Tooltip.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import React, { cloneElement, useId, useState } from 'react';

export type TooltipPlacement = 'top' | 'bottom' | 'left' | 'right';

export interface TooltipProps {
content: React.ReactNode;
children: React.ReactElement;
placement?: TooltipPlacement;
}

const PLACEMENT_STYLES: Record<TooltipPlacement, React.CSSProperties> = {
top: { bottom: '100%', left: '50%', transform: 'translateX(-50%)', marginBottom: 6 },
bottom: { top: '100%', left: '50%', transform: 'translateX(-50%)', marginTop: 6 },
left: { right: '100%', top: '50%', transform: 'translateY(-50%)', marginRight: 6 },
right: { left: '100%', top: '50%', transform: 'translateY(-50%)', marginLeft: 6 },
};

const tooltipStyle: React.CSSProperties = {
position: 'absolute',
zIndex: 50,
whiteSpace: 'nowrap',
borderRadius: 6,
background: '#111827',
color: '#ffffff',
fontSize: 12,
padding: '4px 8px',
pointerEvents: 'none',
};

/** Accessible tooltip shown on hover and keyboard focus, dismissible with Escape. */
export function Tooltip({ content, children, placement = 'top' }: TooltipProps) {
const [open, setOpen] = useState(false);
const id = useId();

return (
<span
style={{ position: 'relative', display: 'inline-flex' }}
onMouseEnter={() => setOpen(true)}
onMouseLeave={() => setOpen(false)}
onFocus={() => setOpen(true)}
onBlur={() => setOpen(false)}
onKeyDown={(e) => {
if (e.key === 'Escape') setOpen(false);
}}
>
{cloneElement(children, { 'aria-describedby': open ? id : undefined })}
{open ? (
<span role="tooltip" id={id} style={{ ...tooltipStyle, ...PLACEMENT_STYLES[placement] }}>
{content}
</span>
) : null}
</span>
);
}
3 changes: 3 additions & 0 deletions react/src/components/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,6 @@ export type { AlertProps, AlertVariant } from './Alert';

export { Badge } from './Badge';
export type { BadgeProps, BadgeVariant, BadgeSize } from './Badge';

export { Tooltip } from './Tooltip';
export type { TooltipProps, TooltipPlacement } from './Tooltip';
Loading