From 28d25421ab4fce1560ef6eb01bb071178fc7a03c Mon Sep 17 00:00:00 2001 From: Rufai-Ahmed Date: Fri, 26 Jun 2026 21:12:29 +0100 Subject: [PATCH] feat(react): add accessible Tooltip component with tests (#230) --- react/src/components/COMPONENTS.md | 18 +++++++++ react/src/components/Tooltip.test.tsx | 41 ++++++++++++++++++++ react/src/components/Tooltip.tsx | 54 +++++++++++++++++++++++++++ react/src/components/index.ts | 3 ++ 4 files changed, 116 insertions(+) create mode 100644 react/src/components/Tooltip.test.tsx create mode 100644 react/src/components/Tooltip.tsx diff --git a/react/src/components/COMPONENTS.md b/react/src/components/COMPONENTS.md index c17cbbe..b01ece7 100644 --- a/react/src/components/COMPONENTS.md +++ b/react/src/components/COMPONENTS.md @@ -44,3 +44,21 @@ provided the badge becomes a keyboard-focusable interactive control Verified alert('clicked')}>Dismiss ``` + +## 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 + + + +``` diff --git a/react/src/components/Tooltip.test.tsx b/react/src/components/Tooltip.test.tsx new file mode 100644 index 0000000..c9aeb21 --- /dev/null +++ b/react/src/components/Tooltip.test.tsx @@ -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( + + + , + ); + 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( + + + , + ); + 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(); + }); +}); diff --git a/react/src/components/Tooltip.tsx b/react/src/components/Tooltip.tsx new file mode 100644 index 0000000..df27416 --- /dev/null +++ b/react/src/components/Tooltip.tsx @@ -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 = { + 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 ( + 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 ? ( + + {content} + + ) : null} + + ); +} diff --git a/react/src/components/index.ts b/react/src/components/index.ts index aa7c45f..256b1bb 100644 --- a/react/src/components/index.ts +++ b/react/src/components/index.ts @@ -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';