Files
ersatztv/web/src/components/fieldHelp.test.tsx
T
timothyandtimothy 40a3232d9e
Build ErsatzTV Image / CI toolchain image resolves (push) Successful in 7s
Build ErsatzTV Image / Delimiter ban (release path) (push) Successful in 15s
Build ErsatzTV Image / Build & test (.NET) (push) Successful in 8m57s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Successful in 6m32s
Build ErsatzTV Image / Functional E2E (curl + UI contracts) (push) Successful in 6m18s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (push) Skipped
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (push) Skipped
Build ErsatzTV Image / Build & push image (amd64) (push) Successful in 5m27s
feat(734): field-level progressive disclosure — shared FieldHelp trigger + panel (#841)
Implements the three-level field-help pattern from #734 as a shared component: field name + optional one-sentence summary → a one-short-paragraph panel behind a consistent Info-icon trigger → a future external-docs deep link (`docsHref`, built and typed; no screen passes one yet).

Adopted on FFmpegProfilesScreen (9 fields), documented as docs/spa-conventions.md §15 with decision record `spa.field-progressive-disclosure`, and mirrored into the design-system prototype.

The panel is portalled to document.body: `.ctv-card` sets `overflow: hidden`, which clips a positioned descendant whatever its z-index, and one field's explainer rendered 12px of a 92px paragraph in every state of the Audio card.

A `::before` hover bridge was added and then WITHDRAWN — it held for a vertical descent onto the panel and failed for a diagonal one, leaving a safe sideways exit of 1.25px on an 18px icon. Hover reads the paragraph in place; the panel's interactive content is reached by pinning.

Four cold adversarial review rounds; the first three returned BLOCKED. They found five wrong copy claims across nine paragraphs and two vacuous tests in a row for the same mechanism.

Deferred with owners: #839 (placement verified by hand, not by a test) and #840 (the portal puts a docsHref link at the end of the tab order).

fixes #734

Co-authored-by: Timothy <timothy@noreply.gitea.tblindustries.be>
2026-08-26 18:05:53 +00:00

202 lines
8.5 KiB
TypeScript

import { cleanup, fireEvent, render, screen } from '@testing-library/react';
import { afterEach, describe, expect, it } from 'vitest';
import { FieldHelp } from '.';
const DETAIL = 'A pool of 0 is measured to fail outright; 1 to 63 are untested rather than known-bad.';
function renderHelp(props: Partial<Parameters<typeof FieldHelp>[0]> = {}) {
render(<FieldHelp detail={DETAIL} label="QSV extra hardware frames" {...props} />);
return screen.getByRole('button', { name: 'More about QSV extra hardware frames' });
}
describe('FieldHelp', () => {
afterEach(cleanup);
it('starts collapsed with no dangling references to a panel that does not exist', () => {
const trigger = renderHelp();
expect(trigger).toHaveAttribute('aria-expanded', 'false');
// An aria-controls/aria-describedby pointing at a missing id is worse than none at all.
expect(trigger).not.toHaveAttribute('aria-controls');
expect(trigger).not.toHaveAttribute('aria-describedby');
expect(screen.queryByRole('note')).toBeNull();
});
// role="note" is not a live region, so nothing announces the panel on appearance.
// aria-describedby on the focused trigger is what actually gets the paragraph read out.
it('describes the trigger with the panel while it is open', () => {
const trigger = renderHelp();
fireEvent.focus(trigger);
const panel = screen.getByRole('note');
expect(trigger.getAttribute('aria-describedby')).toBe(panel.id);
expect(trigger.getAttribute('aria-controls')).toBe(panel.id);
});
// The panel is portalled out of the wrapper because .ctv-card clips with overflow:hidden.
it('renders the panel outside the trigger wrapper, as a direct child of document.body', () => {
const trigger = renderHelp();
const wrap = trigger.parentElement as HTMLElement;
fireEvent.click(trigger);
const panel = screen.getByRole('note');
expect(wrap.contains(panel)).toBe(false);
expect(panel.parentElement).toBe(document.body);
});
// Tap is the only opening gesture available to a touch user, so it must work on its own —
// this is the assertion that a hover-only implementation would fail.
it('toggles open and closed on click/tap', () => {
const trigger = renderHelp();
fireEvent.click(trigger);
expect(trigger).toHaveAttribute('aria-expanded', 'true');
expect(screen.getByRole('note')).toHaveTextContent(DETAIL);
expect(trigger.getAttribute('aria-controls')).toBe(screen.getByRole('note').id);
fireEvent.click(trigger);
expect(screen.queryByRole('note')).toBeNull();
});
// The trigger can sit inside a <label> (components/forms.tsx wraps its control in one). Per the
// HTML spec a label's activation behaviour is skipped for events targeted at interactive content,
// and Chromium was verified to do exactly that, so preventDefault here guards a case no engine is
// currently known to hit — it is belt-and-braces, not a fix for an observed bug. The assertion is
// on the mechanism rather than on "the input did not focus": jsdom does not implement label
// activation at all (a positive control left focus on <body>), so the obvious assertion would
// pass vacuously. fireEvent.click returns false exactly when the handler called preventDefault.
it('prevents the default click action, belt-and-braces against a wrapping label', () => {
const trigger = renderHelp();
expect(fireEvent.click(trigger)).toBe(false);
});
it('opens on keyboard focus and closes on blur', () => {
const trigger = renderHelp();
fireEvent.focus(trigger);
expect(screen.getByRole('note')).toHaveTextContent(DETAIL);
fireEvent.blur(trigger);
expect(screen.queryByRole('note')).toBeNull();
});
it('opens on hover and closes when the pointer leaves', () => {
const trigger = renderHelp();
const wrap = trigger.parentElement as HTMLElement;
fireEvent.mouseEnter(wrap);
expect(screen.getByRole('note')).toHaveTextContent(DETAIL);
fireEvent.mouseLeave(wrap);
expect(screen.queryByRole('note')).toBeNull();
});
it('keeps a pinned panel open after the pointer leaves', () => {
const trigger = renderHelp();
const wrap = trigger.parentElement as HTMLElement;
fireEvent.click(trigger);
fireEvent.mouseEnter(wrap);
fireEvent.mouseLeave(wrap);
expect(screen.getByRole('note')).toHaveTextContent(DETAIL);
});
// NOT asserted here: that the pointer can travel from the trigger onto the panel on hover alone.
// A `::before` bridge across the 7px gap was tried and withdrawn — it held for a strictly vertical
// descent and failed for a diagonal one (the safe sideways corridor measured 1.25px of an 18px
// icon), so the panel's interactive content is reached by PINNING, which is what §15 now says. A
// jsdom test of that transit would also have to fire mouseEnter(panel) before mouseLeave(wrap) —
// the reverse of the browser's own order — and so could only ever confirm its own arrangement.
it('keeps a panel open while the pointer is on the panel itself, and closes when it leaves', () => {
const trigger = renderHelp({ docsHref: 'https://example.invalid/docs' });
const wrap = trigger.parentElement as HTMLElement;
fireEvent.mouseEnter(wrap);
fireEvent.mouseEnter(screen.getByRole('note'));
// Leave the WRAPPER first, so `hoveredPanel` is the only signal still holding the panel open.
// Without this the wrapper's own hover keeps it up and the assertions below pass with the
// panel's handlers deleted — React propagates portal events through the React tree, so
// mouseLeave(panel) also fires the wrapper's onMouseLeave.
fireEvent.mouseLeave(wrap);
expect(screen.getByRole('note')).toBeInTheDocument();
expect(screen.getByRole('link', { name: /Learn more/ })).toBeInTheDocument();
fireEvent.mouseLeave(screen.getByRole('note'));
expect(screen.queryByRole('note')).toBeNull();
});
// Pinning is the documented route to the level-3 link: it survives any pointer motion, and it is
// the only gesture a touch user has.
it('keeps the docs link reachable once the panel is pinned, through arbitrary pointer motion', () => {
const trigger = renderHelp({ docsHref: 'https://example.invalid/docs' });
const wrap = trigger.parentElement as HTMLElement;
fireEvent.click(trigger);
fireEvent.mouseEnter(wrap);
fireEvent.mouseLeave(wrap);
expect(screen.getByRole('link', { name: /Learn more/ })).toBeInTheDocument();
});
// A document-level Escape handler armed by hover alone would steal focus from whatever the user
// was actually dismissing, whenever a pointer happened to be resting on an info icon.
it('leaves Escape alone while the panel is open on hover only', () => {
render(<input data-testid="elsewhere" />);
const trigger = renderHelp();
const wrap = trigger.parentElement as HTMLElement;
const elsewhere = screen.getByTestId('elsewhere');
elsewhere.focus();
fireEvent.mouseEnter(wrap);
expect(screen.getByRole('note')).toBeInTheDocument();
fireEvent.keyDown(document, { key: 'Escape' });
expect(screen.getByRole('note')).toBeInTheDocument();
expect(document.activeElement).toBe(elsewhere);
});
it('closes a pinned panel on Escape and returns focus to the trigger', () => {
const trigger = renderHelp();
fireEvent.click(trigger);
expect(screen.getByRole('note')).toBeInTheDocument();
fireEvent.keyDown(document, { key: 'Escape' });
expect(screen.queryByRole('note')).toBeNull();
expect(document.activeElement).toBe(trigger);
});
it('closes a pinned panel on an outside pointer press but not an inside one', () => {
const trigger = renderHelp({ docsHref: 'https://example.invalid/docs' });
fireEvent.click(trigger);
fireEvent.pointerDown(screen.getByRole('link', { name: /Learn more/ }));
expect(screen.getByRole('note')).toBeInTheDocument();
fireEvent.pointerDown(document.body);
expect(screen.queryByRole('note')).toBeNull();
});
it('renders the level-3 docs affordance only when a target is supplied', () => {
const trigger = renderHelp();
fireEvent.click(trigger);
expect(screen.queryByRole('link')).toBeNull();
cleanup();
const withDocs = renderHelp({ docsHref: 'https://example.invalid/docs', docsLabel: 'Full reference' });
fireEvent.click(withDocs);
const link = screen.getByRole('link', { name: /Full reference/ });
expect(link).toHaveAttribute('href', 'https://example.invalid/docs');
expect(link).toHaveAttribute('target', '_blank');
expect(link).toHaveAttribute('rel', 'noreferrer');
});
});