fix(TermsGate): aria-hidden backdrop, aria-disabled submit button, ellipsis UX #864

Closed
fullstack-engineer wants to merge 1 commits from fix/issue-854-termsgate-a11y into staging
2 changed files with 102 additions and 54 deletions

View File

@ -67,6 +67,8 @@ export function TermsGate({ children }: { children: React.ReactNode }) {
throw new Error(`${res.status}: ${text}`);
}
setStatus("accepted");
// Keep submitting=true while status transitions to "accepted" so the ellipsis
// stays visible until the dialog is removed from the DOM.
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
setSubmitting(false);
@ -87,64 +89,63 @@ export function TermsGate({ children }: { children: React.ReactNode }) {
<>
{children}
{status === "pending" && (
// Backdrop is decorative — does NOT carry aria-hidden anymore.
// The earlier version put aria-hidden="true" on this wrapper,
// which hid the dialog AND its descendants from screen readers,
// making the entire terms-acceptance flow invisible to AT users.
// Backdrop click intentionally does nothing — this is a hard
// gate.
<div className="fixed inset-0 z-50 flex items-center justify-center bg-surface/80 backdrop-blur-sm">
<>
{/* Backdrop is decorative — aria-hidden so screen readers reach the dialog below */}
<div
aria-hidden="true"
className="fixed inset-0 z-50 bg-surface/80 backdrop-blur-sm"
/>
{/* Dialog is the accessible landmark; click does nothing (hard gate) */}
<div
role="dialog"
aria-modal="true"
aria-labelledby="terms-dialog-title"
aria-describedby="terms-dialog-body"
className="mx-4 max-w-lg rounded-lg border border-line bg-surface-sunken p-6 shadow-xl"
className="fixed inset-0 z-50 flex items-center justify-center"
>
<h2 id="terms-dialog-title" className="text-lg font-semibold text-ink">Terms &amp; conditions</h2>
<div id="terms-dialog-body">
<p className="mt-3 text-sm text-ink-mid">
Before you create an organization, please review our{" "}
<a
href="/legal/terms"
className="text-accent underline underline-offset-2 hover:text-accent-strong focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/60 rounded-sm"
target="_blank"
rel="noreferrer"
<div className="mx-4 max-w-lg rounded-lg border border-line bg-surface-sunken p-6 shadow-xl">
<h2 id="terms-dialog-title" className="text-lg font-semibold text-ink">Terms &amp; conditions</h2>
<div id="terms-dialog-body">
<p className="mt-3 text-sm text-ink-mid">
Before you create an organization, please review our{" "}
<a
href="/legal/terms"
className="text-accent underline underline-offset-2 hover:text-accent-strong focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/60 rounded-sm"
target="_blank"
rel="noreferrer"
>
Terms of Service
</a>{" "}
and{" "}
<a
href="/legal/privacy"
className="text-accent underline underline-offset-2 hover:text-accent-strong focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/60 rounded-sm"
target="_blank"
rel="noreferrer"
>
Privacy Policy
</a>
. Click agree to continue.
</p>
<p className="mt-3 text-xs text-ink-mid">
By agreeing you acknowledge that workspace data is stored in AWS us-east-2 (Ohio, United States).
</p>
</div>
{error && <p role="alert" className="mt-3 text-sm text-bad">{error}</p>}
<div className="mt-5 flex justify-end gap-2">
<button
type="button"
ref={agreeButtonRef}
onClick={accept}
aria-disabled={submitting}
className="rounded bg-emerald-700 hover:bg-emerald-800 px-4 py-2 text-sm font-medium text-white aria-disabled:opacity-50 aria-disabled:cursor-not-allowed transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-emerald-400/70 focus-visible:ring-offset-2 focus-visible:ring-offset-surface-sunken"
>
Terms of Service
</a>{" "}
and{" "}
<a
href="/legal/privacy"
className="text-accent underline underline-offset-2 hover:text-accent-strong focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/60 rounded-sm"
target="_blank"
rel="noreferrer"
>
Privacy Policy
</a>
. Click agree to continue.
</p>
<p className="mt-3 text-xs text-ink-mid">
By agreeing you acknowledge that workspace data is stored in AWS us-east-2 (Ohio, United States).
</p>
</div>
{error && <p role="alert" className="mt-3 text-sm text-bad">{error}</p>}
<div className="mt-5 flex justify-end gap-2">
<button
type="button"
ref={agreeButtonRef}
onClick={accept}
disabled={submitting}
// Hover goes DARKER, not lighter — emerald-500 on white
// text drops contrast below AA vs emerald-700. Same trap
// I fixed in ApprovalBanner + ConfirmDialog.
className="rounded bg-emerald-600 hover:bg-emerald-700 px-4 py-2 text-sm font-medium text-white disabled:opacity-50 transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-emerald-400/70 focus-visible:ring-offset-2 focus-visible:ring-offset-surface-sunken"
>
{submitting ? "Saving…" : "I agree"}
</button>
{submitting ? "…" : "I agree"}
</button>
</div>
</div>
</div>
</div>
</>
)}
{status === "error" && (
<div role="alert" className="fixed bottom-4 left-4 right-4 mx-auto max-w-md rounded border border-red-800 bg-red-950 p-3 text-sm text-red-200">

View File

@ -181,11 +181,58 @@ describe("TermsGate — accept flow", () => {
expect(screen.getByRole("dialog")).toBeTruthy();
});
it.skip("disables the button while submitting (requires fake-timers around fireEvent.click)", async () => {
// This test requires vi.useFakeTimers() + act(() => { fireEvent.click(btn); vi.runAllTimers(); })
// to synchronously advance through the async boundary between click and fetch initiation.
// The current test structure fires the fetch before click, so this is skipped pending
// a refactor of the component to not initiate fetch synchronously on user gesture.
it("shows ellipsis on the button while submitting", async () => {
mockFetch(new Response(JSON.stringify({ accepted: false }), { status: 200 }));
// Never resolve — network stays "in flight" indefinitely
let holdResolver: () => void;
const neverResolves = new Promise<void>((r) => { holdResolver = r; });
vi.spyOn(global, "fetch").mockImplementation(() => neverResolves as unknown as Response);
render(<TermsGate><div>App content</div></TermsGate>);
await waitFor(() => screen.getByRole("dialog"));
const btn = screen.getByRole("button", { name: /i agree/i });
fireEvent.click(btn);
// Ellipsis must appear immediately after click (before fetch resolves)
await waitFor(() => {
expect(screen.getByRole("button", { name: /^…$/ })).toBeTruthy();
});
});
it("sets aria-disabled=true on the button while submitting", async () => {
mockFetch(new Response(JSON.stringify({ accepted: false }), { status: 200 }));
let holdResolver!: () => void;
const neverResolves = new Promise<void>((r) => { holdResolver = r; });
vi.spyOn(global, "fetch").mockImplementation(() => neverResolves as unknown as Response);
render(<TermsGate><div>App content</div></TermsGate>);
await waitFor(() => screen.getByRole("dialog"));
const btn = screen.getByRole("button", { name: /i agree/i });
fireEvent.click(btn);
await waitFor(() => {
expect(btn.getAttribute("aria-disabled")).toBe("true");
});
});
it("button is not disabled (stays focusable) while submitting", async () => {
mockFetch(new Response(JSON.stringify({ accepted: false }), { status: 200 }));
let holdResolver!: () => void;
const neverResolves = new Promise<void>((r) => { holdResolver = r; });
vi.spyOn(global, "fetch").mockImplementation(() => neverResolves as unknown as Response);
render(<TermsGate><div>App content</div></TermsGate>);
await waitFor(() => screen.getByRole("dialog"));
const btn = screen.getByRole("button", { name: /i agree/i });
fireEvent.click(btn);
// aria-disabled keeps the button in tab order — no disabled attribute
await waitFor(() => {
expect(btn.hasAttribute("disabled")).toBe(false);
});
});
});