Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions components/contact-form.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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}
Expand Down Expand Up @@ -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}
Expand All @@ -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}
Expand Down
27 changes: 20 additions & 7 deletions components/donations/support-request-form.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
<p id={id} className="text-xs text-destructive">
Expand Down Expand Up @@ -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"
/>
<FieldError id="sr-name-error" message={touched.name ? errors.name : undefined} />
</div>
Expand All @@ -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"
/>
</div>
</div>
Expand All @@ -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"
/>
<FieldError id="sr-email-error" message={touched.email ? errors.email : undefined} />
</div>
Expand All @@ -439,6 +446,7 @@ export function SupportRequestForm() {
onChange={(e) => setField("phone", e.target.value)}
className={inputClass}
placeholder="+599 416 0000"
enterKeyHint="next"
/>
</div>
</div>
Expand Down Expand Up @@ -470,12 +478,7 @@ export function SupportRequestForm() {
<FieldError id="sr-category-error" message={touched.category ? errors.category : undefined} />
</div>

<fieldset
className="space-y-1.5"
aria-describedby={
touched.supportTypes && errors.supportTypes ? "sr-types-error" : undefined
}
>
<fieldset className="space-y-1.5">
<legend className={labelClass}>
Type of support needed{" "}
<span aria-hidden="true" className="text-destructive">*</span>
Expand All @@ -497,7 +500,13 @@ export function SupportRequestForm() {
type="checkbox"
checked={draft.supportTypes.includes(type.value)}
onChange={() => toggleSupportType(type.value)}
className="mt-0.5 h-4 w-4 shrink-0 rounded border-border accent-primary focus:ring-1 focus:ring-primary"
aria-invalid={touched.supportTypes && !!errors.supportTypes}
aria-describedby={
// Focus lands on a checkbox, not the fieldset — the group
// error must be associated with the box itself to be read.
touched.supportTypes && errors.supportTypes ? "sr-types-error" : undefined
}
className="mt-0.5 h-4 w-4 shrink-0 rounded border-border accent-primary focus-visible:ring-1 focus-visible:ring-primary"
/>
{type.label}
</label>
Expand Down Expand Up @@ -530,6 +539,7 @@ export function SupportRequestForm() {
}
className={inputClass}
placeholder="e.g. USD 250, or two sets of snorkel gear"
enterKeyHint="next"
/>
<p id="sr-amount-hint" className="text-xs text-muted-foreground">
Required when asking for financial support or sponsorship. A rough figure is fine.
Expand Down Expand Up @@ -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"
/>
<FieldError id="sr-beneficiaries-error" message={touched.beneficiaries ? errors.beneficiaries : undefined} />
</div>
Expand All @@ -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"
/>
<FieldError id="sr-timing-error" message={touched.timing ? errors.timing : undefined} />
</div>
Expand Down Expand Up @@ -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"
/>
<FieldError id="sr-url-error" message={touched.referenceUrl ? errors.referenceUrl : undefined} />
</div>
Expand Down
20 changes: 12 additions & 8 deletions lib/support-request.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Binary file added review-shots/211-errors/desktop-invalid-form.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added review-shots/211-errors/desktop-invalid.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added review-shots/211-errors/desktop-partial-form.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added review-shots/211-errors/desktop-types-error.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added review-shots/211-errors/desktop-untouched.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added review-shots/211-errors/mobile-invalid.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added review-shots/211-errors/mobile-types-error.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added review-shots/211-errors/mobile-untouched.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
7 changes: 4 additions & 3 deletions tests/e2e/accessibility.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});

Expand Down
8 changes: 8 additions & 0 deletions tests/integration/contact-form.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(<ContactForm />);
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");
});
});
61 changes: 61 additions & 0 deletions tests/integration/donate.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(<SupportRequestForm />);
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(<SupportRequestForm />);
// 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(<SupportRequestForm />);
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(
Expand Down
115 changes: 115 additions & 0 deletions verify-211-errors.mjs
Original file line number Diff line number Diff line change
@@ -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");
Loading