mirror of
https://github.com/shankar0123/certctl.git
synced 2026-06-08 06:28:51 +00:00
c9f932be65
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.
120 lines
4.3 KiB
TypeScript
120 lines
4.3 KiB
TypeScript
// Copyright 2026 certctl LLC. All rights reserved.
|
|
// SPDX-License-Identifier: BUSL-1.1
|
|
//
|
|
// ModalDialog — Phase 5 closure for FE-H3 (3 inline-managed modal
|
|
// pages — SCEPAdminPage, AgentsPage, ESTAdminPage — set
|
|
// role="dialog" + aria-modal="true" + aria-labelledby but no focus
|
|
// trap, no ESC-to-close, no backdrop-click-to-close).
|
|
//
|
|
// Built on Headless UI's <Dialog>, identical pattern to ConfirmDialog
|
|
// (Phase 1) but accepts arbitrary <ModalDialog.Body> content rather
|
|
// than the constrained confirm/cancel button pair ConfirmDialog
|
|
// provides. Use ConfirmDialog for "click YES to do destructive thing";
|
|
// use ModalDialog for "modal that contains a form / multi-action
|
|
// content / a status display".
|
|
//
|
|
// What Headless UI gives us for free (same as ConfirmDialog):
|
|
// • automatic focus trap (Tab/Shift-Tab stays inside the dialog)
|
|
// • automatic ESC-to-close → onClose() callback
|
|
// • automatic backdrop-click-to-close → onClose() callback
|
|
// • role="dialog" + aria-modal="true" on the panel
|
|
// • aria-labelledby on the title node
|
|
// • <Transition> respects prefers-reduced-motion via the global
|
|
// @media block in src/index.css
|
|
//
|
|
// FE-H3 closure scope: the 3 inline-managed modal sites all get
|
|
// migrated to this primitive in the same commit. ConfirmDialog stays
|
|
// as-is for confirm-only flows it already serves.
|
|
|
|
import { Fragment } from 'react';
|
|
import type { ReactNode } from 'react';
|
|
import { Dialog, Transition } from '@headlessui/react';
|
|
|
|
export interface ModalDialogProps {
|
|
/** Controls visibility. Parent owns the boolean. */
|
|
open: boolean;
|
|
/** Title shown at the top — also acts as aria-labelledby target. */
|
|
title: string;
|
|
/** Fires on ESC, backdrop click, or external close trigger. */
|
|
onClose: () => void;
|
|
/**
|
|
* Dialog body — render the form, status, or multi-action content here.
|
|
* The body is wrapped in the styled panel; consumers don't need to
|
|
* wrap their content in another <div>.
|
|
*/
|
|
children: ReactNode;
|
|
/**
|
|
* Footer slot for action buttons. Optional — some modals (e.g. error
|
|
* displays) only show a "Close" affordance which can live inside
|
|
* children. When provided, footer is separated by a top border.
|
|
*/
|
|
footer?: ReactNode;
|
|
/** Maximum width — defaults to `max-w-md` (matches ConfirmDialog). */
|
|
maxWidth?: 'sm' | 'md' | 'lg' | 'xl' | '2xl';
|
|
}
|
|
|
|
const maxWidthMap = {
|
|
sm: 'max-w-sm',
|
|
md: 'max-w-md',
|
|
lg: 'max-w-lg',
|
|
xl: 'max-w-xl',
|
|
'2xl': 'max-w-2xl',
|
|
} as const;
|
|
|
|
export default function ModalDialog({
|
|
open,
|
|
title,
|
|
onClose,
|
|
children,
|
|
footer,
|
|
maxWidth = 'md',
|
|
}: ModalDialogProps) {
|
|
return (
|
|
<Transition show={open} as={Fragment}>
|
|
<Dialog onClose={onClose} className="relative z-50">
|
|
{/* Backdrop. Headless UI wires backdrop-click → onClose. */}
|
|
<Transition.Child
|
|
as={Fragment}
|
|
enter="ease-out duration-200"
|
|
enterFrom="opacity-0"
|
|
enterTo="opacity-100"
|
|
leave="ease-in duration-150"
|
|
leaveFrom="opacity-100"
|
|
leaveTo="opacity-0"
|
|
>
|
|
<div className="fixed inset-0 bg-black/40" aria-hidden="true" />
|
|
</Transition.Child>
|
|
|
|
{/* Panel container. */}
|
|
<div className="fixed inset-0 flex items-center justify-center p-4">
|
|
<Transition.Child
|
|
as={Fragment}
|
|
enter="ease-out duration-200"
|
|
enterFrom="opacity-0 scale-95"
|
|
enterTo="opacity-100 scale-100"
|
|
leave="ease-in duration-150"
|
|
leaveFrom="opacity-100 scale-100"
|
|
leaveTo="opacity-0 scale-95"
|
|
>
|
|
<Dialog.Panel
|
|
className={`bg-surface w-full ${maxWidthMap[maxWidth]} rounded-lg shadow-xl border border-surface-border`}
|
|
>
|
|
<div className="p-6">
|
|
<Dialog.Title className="text-base font-semibold text-ink mb-3">
|
|
{title}
|
|
</Dialog.Title>
|
|
<div className="text-sm text-ink">{children}</div>
|
|
</div>
|
|
{footer && (
|
|
<div className="border-t border-surface-border px-6 py-4 flex justify-end gap-2">
|
|
{footer}
|
|
</div>
|
|
)}
|
|
</Dialog.Panel>
|
|
</Transition.Child>
|
|
</div>
|
|
</Dialog>
|
|
</Transition>
|
|
);
|
|
}
|