feat(ui): add InfoPopover — viewport-aware metric help via Floating UI
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,64 @@
|
|||||||
|
import { render, screen } from '@testing-library/react';
|
||||||
|
import userEvent from '@testing-library/user-event';
|
||||||
|
import { InfoPopover } from '@/components/InfoPopover';
|
||||||
|
|
||||||
|
describe('InfoPopover', () => {
|
||||||
|
it('renders nothing when there is no plain content', () => {
|
||||||
|
const { container } = render(<InfoPopover label="X" />);
|
||||||
|
expect(container).toBeEmptyDOMElement();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders a labelled, collapsed trigger button', () => {
|
||||||
|
render(<InfoPopover plain="what it means" ariaLabel="Reading score" />);
|
||||||
|
const btn = screen.getByRole('button', { name: 'Reading score' });
|
||||||
|
expect(btn).toHaveAttribute('aria-expanded', 'false');
|
||||||
|
expect(screen.queryByRole('tooltip')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('opens on click and shows label, plain and detail', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
render(
|
||||||
|
<InfoPopover
|
||||||
|
label="Reading, Writing & Maths"
|
||||||
|
plain="% reaching the expected standard"
|
||||||
|
detail="National average ~60%"
|
||||||
|
ariaLabel="RWM"
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
await user.click(screen.getByRole('button', { name: 'RWM' }));
|
||||||
|
const tip = await screen.findByRole('tooltip');
|
||||||
|
expect(tip).toHaveTextContent('Reading, Writing & Maths');
|
||||||
|
expect(tip).toHaveTextContent('% reaching the expected standard');
|
||||||
|
expect(tip).toHaveTextContent('National average ~60%');
|
||||||
|
expect(screen.getByRole('button', { name: 'RWM' })).toHaveAttribute(
|
||||||
|
'aria-expanded',
|
||||||
|
'true',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('closes again on a second click', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
render(<InfoPopover plain="body" ariaLabel="Info" />);
|
||||||
|
const btn = screen.getByRole('button', { name: 'Info' });
|
||||||
|
await user.click(btn);
|
||||||
|
expect(await screen.findByRole('tooltip')).toBeInTheDocument();
|
||||||
|
await user.click(btn);
|
||||||
|
expect(screen.queryByRole('tooltip')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('closes on Escape', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
render(<InfoPopover plain="body" ariaLabel="Info" />);
|
||||||
|
await user.click(screen.getByRole('button', { name: 'Info' }));
|
||||||
|
expect(await screen.findByRole('tooltip')).toBeInTheDocument();
|
||||||
|
await user.keyboard('{Escape}');
|
||||||
|
expect(screen.queryByRole('tooltip')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('defaults the accessible name when no ariaLabel is given', () => {
|
||||||
|
render(<InfoPopover plain="body" />);
|
||||||
|
expect(
|
||||||
|
screen.getByRole('button', { name: 'More information' }),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
.icon {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
min-width: 24px;
|
||||||
|
min-height: 24px;
|
||||||
|
margin: -6px 0;
|
||||||
|
padding: 0;
|
||||||
|
border: none;
|
||||||
|
background: none;
|
||||||
|
/* font-size:0 hides the button's own "?" text node; the ::before glyph
|
||||||
|
below carries the visible circled "?" at its own explicit size. */
|
||||||
|
font-size: 0;
|
||||||
|
color: var(--text-muted, #8a7a72);
|
||||||
|
cursor: help;
|
||||||
|
line-height: 1;
|
||||||
|
user-select: none;
|
||||||
|
transition: color 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The visible affordance: a small circled "?" centred in the 24px target. */
|
||||||
|
.icon::before {
|
||||||
|
content: '?';
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 15px;
|
||||||
|
height: 15px;
|
||||||
|
border-radius: 50%;
|
||||||
|
border: 1px solid currentColor;
|
||||||
|
font-size: 0.65rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon:hover,
|
||||||
|
.icon[aria-expanded='true'],
|
||||||
|
.icon:focus-visible {
|
||||||
|
color: var(--accent-coral-dark, #b04a2e);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tooltip {
|
||||||
|
z-index: 9999;
|
||||||
|
width: max-content;
|
||||||
|
max-width: min(260px, calc(100vw - 24px));
|
||||||
|
background: var(--bg-primary, #faf7f2);
|
||||||
|
border: 1px solid var(--border-color, #e8ddd4);
|
||||||
|
border-radius: 10px;
|
||||||
|
box-shadow: 0 4px 16px rgba(44, 36, 32, 0.15);
|
||||||
|
padding: 0.6rem 0.75rem;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.3rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.arrow {
|
||||||
|
fill: var(--bg-primary, #faf7f2);
|
||||||
|
stroke: var(--border-color, #e8ddd4);
|
||||||
|
stroke-width: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.label {
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--text-primary, #2c2420);
|
||||||
|
}
|
||||||
|
|
||||||
|
.plain {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--text-secondary, #5a4a44);
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail {
|
||||||
|
font-size: 0.7rem;
|
||||||
|
color: var(--text-muted, #8a7a72);
|
||||||
|
line-height: 1.4;
|
||||||
|
margin-top: 0.1rem;
|
||||||
|
}
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useRef, useState } from 'react';
|
||||||
|
import {
|
||||||
|
useFloating,
|
||||||
|
autoUpdate,
|
||||||
|
offset,
|
||||||
|
flip,
|
||||||
|
shift,
|
||||||
|
arrow,
|
||||||
|
useHover,
|
||||||
|
useFocus,
|
||||||
|
useClick,
|
||||||
|
useDismiss,
|
||||||
|
useRole,
|
||||||
|
useInteractions,
|
||||||
|
FloatingPortal,
|
||||||
|
FloatingArrow,
|
||||||
|
} from '@floating-ui/react';
|
||||||
|
import styles from './InfoPopover.module.css';
|
||||||
|
|
||||||
|
export interface InfoPopoverProps {
|
||||||
|
label?: string;
|
||||||
|
plain?: string;
|
||||||
|
detail?: string;
|
||||||
|
ariaLabel?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function InfoPopover({ label, plain, detail, ariaLabel }: InfoPopoverProps) {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const arrowRef = useRef<SVGSVGElement>(null);
|
||||||
|
|
||||||
|
const { refs, floatingStyles, context } = useFloating({
|
||||||
|
open,
|
||||||
|
onOpenChange: setOpen,
|
||||||
|
placement: 'top',
|
||||||
|
whileElementsMounted: autoUpdate,
|
||||||
|
middleware: [
|
||||||
|
offset(8),
|
||||||
|
flip({ fallbackAxisSideDirection: 'start' }),
|
||||||
|
shift({ padding: 8 }),
|
||||||
|
arrow({ element: arrowRef, padding: 8 }),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
// Hover (desktop) with a short open delay, keyboard focus, tap (touch),
|
||||||
|
// outside-press + Escape to dismiss. Floating UI disables hover on touch,
|
||||||
|
// so tap and hover never double-fire.
|
||||||
|
const hover = useHover(context, { delay: { open: 100, close: 0 } });
|
||||||
|
const focus = useFocus(context);
|
||||||
|
const click = useClick(context);
|
||||||
|
const dismiss = useDismiss(context);
|
||||||
|
const role = useRole(context, { role: 'tooltip' });
|
||||||
|
const { getReferenceProps, getFloatingProps } = useInteractions([
|
||||||
|
hover,
|
||||||
|
focus,
|
||||||
|
click,
|
||||||
|
dismiss,
|
||||||
|
role,
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (!plain) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
ref={refs.setReference}
|
||||||
|
className={styles.icon}
|
||||||
|
aria-label={ariaLabel ?? 'More information'}
|
||||||
|
aria-expanded={open}
|
||||||
|
{...getReferenceProps()}
|
||||||
|
>
|
||||||
|
?
|
||||||
|
</button>
|
||||||
|
{open && (
|
||||||
|
<FloatingPortal>
|
||||||
|
<div
|
||||||
|
ref={refs.setFloating}
|
||||||
|
className={styles.tooltip}
|
||||||
|
style={floatingStyles}
|
||||||
|
{...getFloatingProps()}
|
||||||
|
>
|
||||||
|
<FloatingArrow ref={arrowRef} context={context} className={styles.arrow} />
|
||||||
|
{label && <span className={styles.label}>{label}</span>}
|
||||||
|
<span className={styles.plain}>{plain}</span>
|
||||||
|
{detail && <span className={styles.detail}>{detail}</span>}
|
||||||
|
</div>
|
||||||
|
</FloatingPortal>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user