mirror of
https://github.com/certctl-io/certctl.git
synced 2026-08-28 08:31:24 +02:00
Closes the Phase 5 batch from cowork/frontend-design-audit.html: ships
the joint UX-H4 + FE-M1 lever (FormField primitive + react-hook-form +
zod schemas) and the FE-H3 fix (Headless UI Dialog focus trap on the 3
inline-managed modals), with an axe-core regression test + CI guard to
prevent UX-H4 regressions.
═════════════════════════ AUDIT VERIFICATION ═════════════════════════
Confirmed live against the repo before implementing:
• Q1 labels / htmlFor / input-id = 139 / 6 / 0
(audit said 138 / 6 / 0 — labels +1, otherwise accurate)
• Q2 no form library installed
(no react-hook-form, formik, @tanstack/react-form, final-form)
• Q3 3 inline-managed dialog sites confirmed:
SCEPAdminPage.tsx:272, AgentsPage.tsx:314, ESTAdminPage.tsx:281
• Q4 audit's top-6 list was OFF — actual top form-heaviest pages
by useState count are: OIDCProviderDetailPage 21, AgentGroupsPage
18, CertificatesPage 17, CertificateDetailPage 14, BreakglassPage
13, ProfilesPage 13 — NOT the audit-suggested OnboardingWizard 5
(now split in Phase 4) / OIDCProvidersPage 8 / IssuersPage 11 /
ProfilesPage 13 / TargetsPage 9 / ApprovalsPage 5. Audit's
intuition skipped the higher-useState pages.
• Q5 jest-dom imported in src/test/setup.ts — axe-core landed
cleanly
═════════════════════════════ CLOSURES ═══════════════════════════════
UX-H4 (label/input binding) — FormField primitive shipped
• web/src/components/FormField.tsx wraps a <label> + an input child
and auto-generates a stable id via React 18's useId(); cloneElement
threads that id onto BOTH the <label htmlFor> AND the child's id
prop so the WCAG 1.3.1 binding holds by construction. Supports
`required` (asterisk + aria-required), `description` (wires
aria-describedby), `error` (aria-invalid + role=alert + extends
aria-describedby). 7 tests pin the contract.
FE-M1 (no form library) — react-hook-form + @hookform/resolvers + zod
• Added react-hook-form 7.75, @hookform/resolvers 5.2, zod 4.4 as
runtime deps; @axe-core/react, jest-axe, @types/jest-axe as devDeps
• Representative migration of CreateTeamModalInline (inside
onboarding/CertificateStep — operator's first-run experience)
from 3-useState + manual handlers to useForm + zodResolver +
FormField. Schema at pages/onboarding/team.schema.ts.
• Per the audit's "top-6 only, primitive is the lever" rule, the
other 5 audit-suggested pages migrate organically as feature
work touches them — documented as Phase 5 follow-up. The
FormField primitive is the leverage point; per-page migrations
are mechanical applications.
FE-H3 (no focus trap on modal pages)
• New ModalDialog primitive at web/src/components/ModalDialog.tsx —
Headless UI Dialog wrapper for arbitrary-content modals
(complements ConfirmDialog which is confirm-only). Auto-emits
role=dialog + aria-modal + aria-labelledby + ESC-to-close +
backdrop-click-to-close + focus trap.
• All 3 inline-managed modal sites migrated:
• SCEPAdminPage ConfirmReloadModal
• ESTAdminPage ConfirmReloadModal (data-testid preserved)
• AgentsPage RetireAgentModal (3-mode: confirm / blocked / error
— title + footer change per mode; body slot stays the same)
• 37/37 existing modal-page tests stay green — no behavior change
visible to the test suite, only the focus-trap + ESC handling.
UX-H4 regression gate
• web/src/test/a11y.test.tsx runs axe-core (not jest-axe — its
`toHaveNoViolations` matcher uses jest's expect API which can't
plug into Vitest's expect.extend; fails with "expectAssertion.call
is not a function"). Direct axe.run + assert violations.length===0
gives the same gate with a readable failure message.
• Scope: primitives, not page sweeps. Primitives carry the risk
surface; pages compose them. 5 tests covering FormField (with +
without description/error), Skeleton (all 4 variants),
ModalDialog, Breadcrumbs. ~400ms total.
• Skeleton.table's empty <th> cells are decorative shimmers inside
a role=status + aria-busy=true tree — axe-core's
`empty-table-header` rule doesn't model aria-busy gating, so it
is suppressed for the Skeleton variant scan with a clear comment.
• scripts/ci-guards/no-unbound-label.sh — fails CI if a new <label>
without htmlFor lands. Baseline-driven (132 today) so the existing
backlog doesn't block CI; every migration to FormField drops the
baseline. `--strict` mode rejects any unbound label once the
backlog clears.
═══════════════════════════ VERIFICATION ═════════════════════════════
• npx tsc --noEmit — exits 0
• New tests: FormField 7/7, ModalDialog 6/6, a11y 5/5 = 18/18 new
• Component suite: 14 files / 150/150 green
• Page suite (representative subset run): 16 files in first run
(timeout truncated final summary) + 10 files / 48/48 in second
run — all green
• OnboardingWizard 4/4 (the migrated CreateTeamModalInline test
case is the second one — `+ New team opens the inline modal,
calls createTeam, invalidates the cache, and auto-selects the
new team`)
• SCEPAdminPage 20/20, ESTAdminPage 14/14, AgentsPage 3/3 — all
37 modal-page tests stay green after ModalDialog migration
• npm run build ✓ in 3.27s
• CI guard: bash scripts/ci-guards/no-unbound-label.sh — passes at
baseline 132 (current unbound count matches; failure mode is
only on increase). --strict path will fail until backlog clears.
═══════════════════════════ RESIDUAL RISK ════════════════════════════
• RHF migration risk: zod resolver's input/output type mismatch
bit me once during this work (description: z.string().optional()
gave Input: string|undefined vs Output: string after .default()).
Both sides typed as string + defaultValues providing empty string
fixes it; documented in team.schema.ts. Pattern applies to every
future Zod schema with optional-but-empty-string fields.
• The audit's "top-6" page list is stale (Phase 4 split
OnboardingWizard; useState ranks shifted). Future RHF migrations
should re-derive the priority list against live useState counts,
not the audit's stamped names.
• DataTable per-row React.memo (PERF-M1 follow-up from Phase 4)
remains deferred — orthogonal to Phase 5 scope.
113 lines
4.4 KiB
TypeScript
113 lines
4.4 KiB
TypeScript
// Phase 5 closure (FE-H3 + UX-H4 regression gate): axe-core a11y
|
|
// assertions on the primitives that other pages reuse. Failing this
|
|
// suite means a future change reintroduced an unbound label, a missing
|
|
// aria-* attr on a primitive, or a similar a11y bug.
|
|
//
|
|
// Implementation notes:
|
|
// • Uses axe-core directly (not jest-axe) — jest-axe's
|
|
// `toHaveNoViolations` matcher uses the jest expect API, which
|
|
// Vitest's expect.extend can't host (TypeError: expectAssertion.call
|
|
// is not a function). Asserting violations.length === 0 with a
|
|
// readable failure message gives the same gate without the
|
|
// compatibility headache.
|
|
// • Scope is primitives, not page sweeps — primitives carry the risk
|
|
// surface, pages mostly compose them. Faster runtime + tighter
|
|
// fail signal when a primitive regresses.
|
|
|
|
import { describe, it, expect } from 'vitest';
|
|
import { render } from '@testing-library/react';
|
|
import axe from 'axe-core';
|
|
import { MemoryRouter } from 'react-router-dom';
|
|
|
|
import FormField from '../components/FormField';
|
|
import ModalDialog from '../components/ModalDialog';
|
|
import Skeleton from '../components/Skeleton';
|
|
import Breadcrumbs from '../components/Breadcrumbs';
|
|
|
|
async function expectNoViolations(
|
|
container: HTMLElement,
|
|
extraSuppressedRules: string[] = [],
|
|
) {
|
|
const suppressed: Record<string, { enabled: false }> = {
|
|
// color-contrast needs computed-styles which jsdom doesn't compute;
|
|
// that rule is suppressed in axe defaults under jsdom anyway but
|
|
// pinning it here keeps the failure mode loud if axe-core changes
|
|
// default behavior.
|
|
'color-contrast': { enabled: false },
|
|
};
|
|
for (const r of extraSuppressedRules) suppressed[r] = { enabled: false };
|
|
const results = await axe.run(container, { rules: suppressed });
|
|
if (results.violations.length > 0) {
|
|
const summary = results.violations
|
|
.map((v) => ` • ${v.id} (${v.impact}): ${v.help} — ${v.nodes.length} node(s)`)
|
|
.join('\n');
|
|
throw new Error(`axe-core found ${results.violations.length} violation(s):\n${summary}`);
|
|
}
|
|
expect(results.violations).toHaveLength(0);
|
|
}
|
|
|
|
describe('Primitives — axe-core a11y assertions', () => {
|
|
it('FormField (label / input pair) has no axe violations', async () => {
|
|
const { container } = render(
|
|
<FormField label="Email address" required>
|
|
<input type="email" />
|
|
</FormField>,
|
|
);
|
|
await expectNoViolations(container);
|
|
});
|
|
|
|
it('FormField with description + error has no axe violations', async () => {
|
|
const { container } = render(
|
|
<FormField
|
|
label="Display name"
|
|
required
|
|
description="What other operators will see"
|
|
error="Must be at least 1 character"
|
|
>
|
|
<input type="text" />
|
|
</FormField>,
|
|
);
|
|
await expectNoViolations(container);
|
|
});
|
|
|
|
it('Skeleton variants have no axe violations (table / page / card / stat)', async () => {
|
|
for (const variant of ['table', 'page', 'card', 'stat'] as const) {
|
|
const { container, unmount } = render(<Skeleton variant={variant} />);
|
|
// Skeleton.table renders empty <th> cells — they're decorative
|
|
// shimmer placeholders inside a role="status" + aria-busy="true"
|
|
// container, so screen readers announce "Loading content" and
|
|
// skip the table semantics. axe-core's `empty-table-header` rule
|
|
// doesn't model aria-busy gating, so suppress it for this variant
|
|
// (and consistently across all variants for the same scan).
|
|
await expectNoViolations(container, ['empty-table-header']);
|
|
unmount();
|
|
}
|
|
});
|
|
|
|
it('ModalDialog with title + body + footer has no axe violations', async () => {
|
|
const { baseElement } = render(
|
|
<ModalDialog
|
|
open={true}
|
|
title="Confirm action"
|
|
onClose={() => {}}
|
|
footer={<button>OK</button>}
|
|
>
|
|
<p>This action is reversible.</p>
|
|
</ModalDialog>,
|
|
);
|
|
// ModalDialog mounts into a portal on document.body — pass
|
|
// baseElement (which is document.body) rather than container so
|
|
// axe scans the actual rendered dialog tree.
|
|
await expectNoViolations(baseElement);
|
|
});
|
|
|
|
it('Breadcrumbs renders no axe violations on a 2-deep path', async () => {
|
|
const { container } = render(
|
|
<MemoryRouter initialEntries={['/issuers/iss-vault']}>
|
|
<Breadcrumbs />
|
|
</MemoryRouter>,
|
|
);
|
|
await expectNoViolations(container);
|
|
});
|
|
});
|