diff --git a/nextjs-app/__tests__/components/InfoPopover.test.tsx b/nextjs-app/__tests__/components/InfoPopover.test.tsx new file mode 100644 index 0000000..526b736 --- /dev/null +++ b/nextjs-app/__tests__/components/InfoPopover.test.tsx @@ -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(); + expect(container).toBeEmptyDOMElement(); + }); + + it('renders a labelled, collapsed trigger button', () => { + render(); + 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( + , + ); + 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(); + 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(); + 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(); + expect( + screen.getByRole('button', { name: 'More information' }), + ).toBeInTheDocument(); + }); +}); diff --git a/nextjs-app/components/InfoPopover.module.css b/nextjs-app/components/InfoPopover.module.css new file mode 100644 index 0000000..297a184 --- /dev/null +++ b/nextjs-app/components/InfoPopover.module.css @@ -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; +} diff --git a/nextjs-app/components/InfoPopover.tsx b/nextjs-app/components/InfoPopover.tsx new file mode 100644 index 0000000..4c5e812 --- /dev/null +++ b/nextjs-app/components/InfoPopover.tsx @@ -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(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 ( + <> + + ? + + {open && ( + + + + {label && {label}} + {plain} + {detail && {detail}} + + + )} + > + ); +}