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
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>
202 lines
8.5 KiB
TypeScript
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');
|
|
});
|
|
});
|