diff --git a/canvas/src/components/TermsGate.tsx b/canvas/src/components/TermsGate.tsx index 6fc2d358..cc236e0d 100644 --- a/canvas/src/components/TermsGate.tsx +++ b/canvas/src/components/TermsGate.tsx @@ -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. -
+ <> + {/* Backdrop is decorative — aria-hidden so screen readers reach the dialog below */} + + )} {status === "error" && (
diff --git a/canvas/src/components/__tests__/TermsGate.test.tsx b/canvas/src/components/__tests__/TermsGate.test.tsx index 2aeee145..c3daed47 100644 --- a/canvas/src/components/__tests__/TermsGate.test.tsx +++ b/canvas/src/components/__tests__/TermsGate.test.tsx @@ -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((r) => { holdResolver = r; }); + vi.spyOn(global, "fetch").mockImplementation(() => neverResolves as unknown as Response); + + render(
App content
); + 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((r) => { holdResolver = r; }); + vi.spyOn(global, "fetch").mockImplementation(() => neverResolves as unknown as Response); + + render(
App content
); + 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((r) => { holdResolver = r; }); + vi.spyOn(global, "fetch").mockImplementation(() => neverResolves as unknown as Response); + + render(
App content
); + 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); + }); }); });