diff --git a/components/contact-form.tsx b/components/contact-form.tsx index 55b6249..5fe0813 100644 --- a/components/contact-form.tsx +++ b/components/contact-form.tsx @@ -288,6 +288,7 @@ export function ContactForm({ initialInterest }: ContactFormProps) { maxLength={meta.maxLength} autoComplete={meta.autoComplete} inputMode={meta.inputMode} + enterKeyHint="next" onChange={(e) => contextualSetters[field](e.target.value)} aria-invalid={!!error} aria-describedby={error ? `${meta.id}-error` : undefined} @@ -332,6 +333,7 @@ export function ContactForm({ initialInterest }: ContactFormProps) { value={name} maxLength={CONTACT_LIMITS.name} autoComplete="name" + enterKeyHint="next" onChange={(e) => updateCoreField("name", e.target.value)} onBlur={() => handleBlur("name")} aria-invalid={touched.name && !!errors.name} @@ -356,6 +358,7 @@ export function ContactForm({ initialInterest }: ContactFormProps) { value={email} maxLength={CONTACT_LIMITS.email} autoComplete="email" + enterKeyHint="next" onChange={(e) => updateCoreField("email", e.target.value)} onBlur={() => handleBlur("email")} aria-invalid={touched.email && !!errors.email} diff --git a/components/donations/support-request-form.tsx b/components/donations/support-request-form.tsx index 1a939fc..e93fa14 100644 --- a/components/donations/support-request-form.tsx +++ b/components/donations/support-request-form.tsx @@ -40,6 +40,10 @@ const inputClass = const labelClass = "text-sm font-medium text-foreground"; function FieldError({ id, message }: { id: string; message?: string }) { + // Mounts only with a message. A permanently reserved slot pads every + // untouched field in this long form; a small local expansion when a + // message appears is the better trade. `aria-describedby` points here + // only under the same condition, so the reference never dangles. if (!message) return null; return (
@@ -378,6 +382,7 @@ export function SupportRequestForm() { aria-describedby={touched.name && errors.name ? "sr-name-error" : undefined} className={inputClass} placeholder="Your full name" + enterKeyHint="next" /> @@ -397,6 +402,7 @@ export function SupportRequestForm() { onChange={(e) => setField("organization", e.target.value)} className={inputClass} placeholder="e.g. Saba youth football club" + enterKeyHint="next" /> @@ -420,6 +426,7 @@ export function SupportRequestForm() { aria-describedby={touched.email && errors.email ? "sr-email-error" : undefined} className={inputClass} placeholder="you@example.com" + enterKeyHint="next" /> @@ -439,6 +446,7 @@ export function SupportRequestForm() { onChange={(e) => setField("phone", e.target.value)} className={inputClass} placeholder="+599 416 0000" + enterKeyHint="next" /> @@ -470,12 +478,7 @@ export function SupportRequestForm() { -
Required when asking for financial support or sponsorship. A rough figure is fine. @@ -604,6 +614,7 @@ export function SupportRequestForm() { aria-describedby={touched.beneficiaries && errors.beneficiaries ? "sr-beneficiaries-error" : undefined} className={inputClass} placeholder="e.g. About 30 kids aged 8–14" + enterKeyHint="next" /> @@ -625,6 +636,7 @@ export function SupportRequestForm() { aria-describedby={touched.timing && errors.timing ? "sr-timing-error" : undefined} className={inputClass} placeholder="e.g. October 2026, or ongoing" + enterKeyHint="next" /> @@ -689,6 +701,7 @@ export function SupportRequestForm() { aria-describedby={touched.referenceUrl && errors.referenceUrl ? "sr-url-error" : undefined} className={inputClass} placeholder="https://… event page, club site, or social post" + enterKeyHint="next" /> diff --git a/lib/support-request.ts b/lib/support-request.ts index dd15a74..66d6444 100644 --- a/lib/support-request.ts +++ b/lib/support-request.ts @@ -5,14 +5,18 @@ import { } from "@/data/community-support"; /** - * Client-email handoff for the /donate "Request Support from Sea Saba" form — - * the same submission mechanism as the contact form. The site builds a - * pre-populated `mailto:` draft (or a WhatsApp message); the visitor's own - * email app sends it, so Respond.io sees the visitor's real From address and - * the request lands on the correct contact. Server-side sending was removed - * site-wide because a common `From` address collapsed all visitors into one - * Respond.io contact — see issue #104 for the deferred Custom Channel - * architecture. Do not reintroduce a shared-sender server endpoint here. + * Validation and fallback handoffs for the /donate "Request Support from + * Sea Saba" form. The primary submission path is the first-party + * persistence boundary POST /api/support-requests (#189), which saves the + * request in the Community Support system and returns a reference. These + * helpers cover the recoverable-failure path: when the boundary can't be + * reached, the site builds a pre-populated `mailto:` draft (or a WhatsApp + * message); the visitor's own email app sends it, so Respond.io sees the + * visitor's real From address and the request lands on the correct + * contact. A shared-sender server endpoint must not be reintroduced for + * the email path — a common `From` address collapsed all visitors into one + * Respond.io contact (see issue #104 for the deferred Custom Channel + * architecture). * * Pure functions — no Next.js or provider imports — so the submission * contract is unit-testable in isolation. diff --git a/review-shots/211-errors/desktop-invalid-form.png b/review-shots/211-errors/desktop-invalid-form.png new file mode 100644 index 0000000..20b5d5c Binary files /dev/null and b/review-shots/211-errors/desktop-invalid-form.png differ diff --git a/review-shots/211-errors/desktop-invalid.png b/review-shots/211-errors/desktop-invalid.png new file mode 100644 index 0000000..f7b683a Binary files /dev/null and b/review-shots/211-errors/desktop-invalid.png differ diff --git a/review-shots/211-errors/desktop-partial-form.png b/review-shots/211-errors/desktop-partial-form.png new file mode 100644 index 0000000..96bd8ff Binary files /dev/null and b/review-shots/211-errors/desktop-partial-form.png differ diff --git a/review-shots/211-errors/desktop-types-error.png b/review-shots/211-errors/desktop-types-error.png new file mode 100644 index 0000000..71254ba Binary files /dev/null and b/review-shots/211-errors/desktop-types-error.png differ diff --git a/review-shots/211-errors/desktop-untouched-form.png b/review-shots/211-errors/desktop-untouched-form.png new file mode 100644 index 0000000..7b7333f Binary files /dev/null and b/review-shots/211-errors/desktop-untouched-form.png differ diff --git a/review-shots/211-errors/desktop-untouched.png b/review-shots/211-errors/desktop-untouched.png new file mode 100644 index 0000000..0b10f8e Binary files /dev/null and b/review-shots/211-errors/desktop-untouched.png differ diff --git a/review-shots/211-errors/mobile-invalid.png b/review-shots/211-errors/mobile-invalid.png new file mode 100644 index 0000000..0069c76 Binary files /dev/null and b/review-shots/211-errors/mobile-invalid.png differ diff --git a/review-shots/211-errors/mobile-types-error.png b/review-shots/211-errors/mobile-types-error.png new file mode 100644 index 0000000..732033c Binary files /dev/null and b/review-shots/211-errors/mobile-types-error.png differ diff --git a/review-shots/211-errors/mobile-untouched.png b/review-shots/211-errors/mobile-untouched.png new file mode 100644 index 0000000..2486a0a Binary files /dev/null and b/review-shots/211-errors/mobile-untouched.png differ diff --git a/tests/e2e/accessibility.spec.ts b/tests/e2e/accessibility.spec.ts index 3e7e5e3..0264932 100644 --- a/tests/e2e/accessibility.spec.ts +++ b/tests/e2e/accessibility.spec.ts @@ -77,9 +77,10 @@ test.describe("automated axe scans", () => { test.skip(isMobile || browserName !== "chromium", "representative scan on desktop-chromium"); await hydratedGoto(page, "/donate", "#sr-name"); await page.getByRole("button", { name: "Send request" }).click(); - // Error text is always mounted but `invisible` until the field is touched. - await expect(page.locator("#sr-name-error")).not.toHaveClass(/invisible/); - await expect(page.locator("#sr-email-error")).not.toHaveClass(/invisible/); + // Errors mount on demand under each field once validation runs. + await expect(page.locator("#sr-name-error")).toBeVisible(); + await expect(page.locator("#sr-email-error")).toBeVisible(); + await expect(page.locator("#sr-types-error")).toBeVisible(); await expectNoAxeViolations(page); }); diff --git a/tests/integration/contact-form.test.tsx b/tests/integration/contact-form.test.tsx index b3fa96e..c2a5dc8 100644 --- a/tests/integration/contact-form.test.tsx +++ b/tests/integration/contact-form.test.tsx @@ -356,4 +356,12 @@ describe("required semantics and error recovery (#218)", () => { expect(document.activeElement).toBe(document.getElementById("email")) ); }); + + it("marks single-line fields with next-field keyboard hints (#211)", () => { + render(); + expect(screen.getByRole("textbox", { name: /^Name/ })).toHaveAttribute("enterkeyhint", "next"); + expect(screen.getByRole("textbox", { name: /^Email/ })).toHaveAttribute("enterkeyhint", "next"); + // The message field is a textarea — Enter inserts a newline there. + expect(screen.getByRole("textbox", { name: /^Message/ })).not.toHaveAttribute("enterkeyhint"); + }); }); diff --git a/tests/integration/donate.test.tsx b/tests/integration/donate.test.tsx index a311aa3..20d73c3 100644 --- a/tests/integration/donate.test.tsx +++ b/tests/integration/donate.test.tsx @@ -951,6 +951,67 @@ describe("support request form", () => { expect(fetch).not.toHaveBeenCalled(); }); + it("associates the support-type group error with the checkboxes that receive focus (#211)", async () => { + render(); + await userEvent.click(screen.getByRole("button", { name: "Send request" })); + // Focus lands on a checkbox, not the fieldset — the group error must be + // described by the boxes themselves or it is never announced. + const boxes = document.querySelectorAll('input[id^="sr-type-"]'); + expect(boxes.length).toBeGreaterThan(0); + for (const box of boxes) { + expect(box).toHaveAttribute("aria-invalid", "true"); + expect(box).toHaveAttribute("aria-describedby", "sr-types-error"); + } + expect(document.getElementById("sr-types-error")).toHaveTextContent( + "Please choose at least one type of support." + ); + }); + + it("mounts errors on demand — an untouched form reserves no error space (#211)", async () => { + render(); + // No error element exists before validation: the untouched form keeps + // its compact rhythm instead of padding for errors that may never appear. + expect(document.querySelector('[id$="-error"]')).toBeNull(); + expect(screen.queryByText(/please enter your name/i)).toBeNull(); + + await userEvent.click(screen.getByRole("button", { name: "Send request" })); + // The message mounts directly under its field and stays wired to it. + const nameError = document.getElementById("sr-name-error"); + expect(nameError).toBeVisible(); + expect(nameError).toHaveTextContent("Please enter your name."); + expect(screen.getByRole("textbox", { name: /your name/i })).toHaveAttribute( + "aria-describedby", + "sr-name-error" + ); + + // Correcting the field unmounts the message and drops the association. + await userEvent.type(screen.getByRole("textbox", { name: /your name/i }), "Sentinel Person"); + expect(document.getElementById("sr-name-error")).toBeNull(); + expect(screen.getByRole("textbox", { name: /your name/i })).not.toHaveAttribute( + "aria-describedby" + ); + }); + + it("marks single-line fields with next-field keyboard hints (#211)", () => { + render(); + for (const id of [ + "sr-name", + "sr-organization", + "sr-email", + "sr-phone", + "sr-amount", + "sr-beneficiaries", + "sr-timing", + "sr-url", + ]) { + expect(document.getElementById(id)).toHaveAttribute("enterkeyhint", "next"); + } + // Textareas and checkboxes keep their natural keys — a "next" hint + // would be wrong where Enter inserts a newline or toggles. + expect(document.getElementById("sr-description")).not.toHaveAttribute("enterkeyhint"); + expect(document.getElementById("sr-ack")).not.toHaveAttribute("enterkeyhint"); + }); + it("announces the pending state while the request is in flight", { timeout: 20000 }, async () => { let resolveFetch: ((r: Response) => void) | undefined; vi.mocked(fetch).mockImplementation( diff --git a/verify-211-errors.mjs b/verify-211-errors.mjs new file mode 100644 index 0000000..4305691 --- /dev/null +++ b/verify-211-errors.mjs @@ -0,0 +1,115 @@ +import { chromium } from "playwright"; +import { mkdirSync } from "node:fs"; + +// #211 follow-up: the support-request form dropped permanently reserved +// error slots — errors now mount on demand. Verify the untouched form is +// compact again and that appearing errors only grow their own field group. +// Shots -> review-shots/211-errors/. +const BASE = process.env.BASE_URL ?? "http://localhost:3001"; +const OUT = process.env.OUT ?? "review-shots/211-errors/"; +mkdirSync(OUT, { recursive: true }); + +const browser = await chromium.launch(); + +const hideCookiebot = async (page) => { + await page.addStyleTag({ + content: "[id^='CybotCookiebot'],[id*='Cookiebot']{display:none!important}", + }).catch(() => {}); +}; + +const openDonate = async (page) => { + await page.goto(BASE + "/donate", { waitUntil: "load" }); + await page.waitForSelector("#sr-name"); + await page.waitForTimeout(800); + await hideCookiebot(page); +}; + +// Bounding-box metrics for the error-slot question: element presence and +// the vertical gap between consecutive field rows. +const rhythm = () => { + const rows = ["#sr-name", "#sr-email", "#sr-category", "#sr-amount", "#sr-request"]; + const slots = [...document.querySelectorAll('[id$="-error"]')].map((el) => el.id); + const gaps = []; + for (let i = 0; i + 1 < rows.length; i++) { + const a = document.querySelector(rows[i]); + const b = document.querySelector(rows[i + 1]); + if (a && b) gaps.push(Math.round(b.getBoundingClientRect().top - a.getBoundingClientRect().bottom)); + } + const form = document.querySelector("#sr-name")?.closest("form"); + return { mountedErrorIds: slots, rowGaps: gaps, formHeight: form ? Math.round(form.getBoundingClientRect().height) : null }; +}; + +// Fill every required field except the support-type checkbox group, so the +// group error is the only one left after submit. +const fillAllButTypes = async (page) => { + await page.locator("#sr-name").fill("Visual Check"); + await page.locator("#sr-email").fill("visual@example.test"); + await page.locator("#sr-category").selectOption("youth"); + await page.locator("#sr-request").fill("Sponsorship of team uniforms"); + await page.locator("#sr-description").fill("A youth football season for island kids."); + await page.locator("#sr-beneficiaries").fill("About 30 kids aged 8-14"); + await page.locator("#sr-timing").fill("October 2026"); + await page.locator("#sr-use").fill("Uniforms and league fees"); + await page.locator("#sr-ack").check(); +}; + +// ---- Desktop ------------------------------------------------------------- +{ + const page = await browser.newPage({ viewport: { width: 1440, height: 1000 } }); + await openDonate(page); + const form = page.locator("form").filter({ has: page.locator("#sr-name") }).first(); + await form.scrollIntoViewIfNeeded(); + console.log("desktop untouched:", JSON.stringify(await page.evaluate(rhythm))); + await page.screenshot({ path: `${OUT}desktop-untouched.png` }); + await form.screenshot({ path: `${OUT}desktop-untouched-form.png` }); + + await page.getByRole("button", { name: "Send request" }).click(); + await page.waitForSelector("#sr-types-error"); + await page.waitForTimeout(300); + console.log("desktop invalid:", JSON.stringify(await page.evaluate(rhythm))); + await page.screenshot({ path: `${OUT}desktop-invalid.png` }); + await form.screenshot({ path: `${OUT}desktop-invalid-form.png` }); + + // Partially corrected: fix name + email, leave the rest invalid. + await page.locator("#sr-name").fill("Visual Check"); + await page.locator("#sr-email").fill("visual@example.test"); + await page.waitForTimeout(300); + console.log("desktop partial:", JSON.stringify(await page.evaluate(rhythm))); + await form.screenshot({ path: `${OUT}desktop-partial-form.png` }); + await page.close(); +} + +// ---- Mobile -------------------------------------------------------------- +{ + const page = await browser.newPage({ viewport: { width: 390, height: 844 } }); + await openDonate(page); + const form = page.locator("form").filter({ has: page.locator("#sr-name") }).first(); + await form.scrollIntoViewIfNeeded(); + console.log("mobile untouched:", JSON.stringify(await page.evaluate(rhythm))); + await page.screenshot({ path: `${OUT}mobile-untouched.png` }); + + await page.getByRole("button", { name: "Send request" }).click(); + await page.waitForSelector("#sr-types-error"); + await page.waitForTimeout(300); + console.log("mobile invalid:", JSON.stringify(await page.evaluate(rhythm))); + await page.screenshot({ path: `${OUT}mobile-invalid.png` }); + await page.close(); +} + +// Checkbox-group error in isolation (mobile + desktop). +for (const [w, h, tag] of [[390, 844, "mobile"], [1440, 1000, "desktop"]]) { + const page = await browser.newPage({ viewport: { width: w, height: h } }); + await openDonate(page); + await fillAllButTypes(page); + await page.getByRole("button", { name: "Send request" }).click(); + await page.waitForSelector("#sr-types-error"); + await page.waitForTimeout(300); + const fieldset = page.locator("fieldset"); + await fieldset.scrollIntoViewIfNeeded(); + await page.screenshot({ path: `${OUT}${tag}-types-error.png` }); + console.log(`${tag} group error:`, JSON.stringify(await page.evaluate(rhythm))); + await page.close(); +} + +await browser.close(); +console.log("done");