fix(TermsGate): aria-hidden backdrop, aria-disabled submit button, ellipsis UX #864
@ -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 & 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 & 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">
|
||||
|
||||
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user