[Story] Contact page #6

Closed
bot-implementer wants to merge 6 commits from feature/5 into master
6 changed files with 274 additions and 0 deletions
+44
View File
@@ -0,0 +1,44 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Contact | My Personal Blog</title>
<link rel="stylesheet" href="css/style.css" />
</head>
<body>
<header class="site-header">
<nav class="site-nav" aria-label="Main navigation">
<a class="brand" href="index.html">My Blog</a>
<ul class="nav-links">
<li><a href="index.html">Home</a></li>
<li><a href="contact.html" aria-current="page">Contact</a></li>
</ul>
</nav>
</header>
<main>
<h1>Contact</h1>
<p>Use the form below to get in touch — submitting it opens your mail client with the details pre-filled.</p>
<form id="contact-form" class="contact-form">
<div class="form-field">
<label for="name">Name</label>
<input type="text" id="name" name="name" required autocomplete="name" />
</div>
<div class="form-field">
<label for="email">Email</label>
<input type="email" id="email" name="email" required autocomplete="email" />
</div>
<div class="form-field">
<label for="message">Message</label>
<textarea id="message" name="message" rows="6" required></textarea>
</div>
<button type="submit">Send message</button>
</form>
</main>
<footer class="site-footer">
<p>&copy; 2026 My Personal Blog</p>
</footer>
<script type="module" src="js/contact.js"></script>
</body>
</html>
+1
View File
@@ -12,6 +12,7 @@
<a class="brand" href="index.html">My Blog</a>
<ul class="nav-links">
<li><a href="index.html">Home</a></li>
<li><a href="contact.html">Contact</a></li>
</ul>
</nav>
</header>
+72
View File
@@ -0,0 +1,72 @@
import { buildMailtoUrl } from "./mailto.js";
/** The address the contact form messages are sent to. */
export const MAILTO_TARGET = "hello@example.com";
/**
* Collect form field values by name.
* Works with a real DOM form and with the minimal fake form used in tests.
*
* @param {{querySelectorAll(selector: string): ArrayLike<{name: string, value: string}>}} form
* @returns {{name: string, email: string, message: string}}
*/
export function readFormValues(form) {
const values = {};
for (const el of form.querySelectorAll("[name]")) {
values[el.name] = el.value;
}
return values;
}
/**
* Build the mailto: URL for the contact form from the entered values.
*
* @param {{name: string, email: string, message: string}} values
* @param {string} [to]
* @returns {string}
*/
export function buildContactMailto(values, to = MAILTO_TARGET) {
const subject = `Contact form submission from ${values.name || "someone"}`;
const body = [
`Name: ${values.name || ""}`,
`Email: ${values.email || ""}`,
"",
"Message:",
values.message || "",
].join("\n");
return buildMailtoUrl({ to, subject, body });
}
/**
* Handle a contact form submit: build the mailto: URL and send the
* visitor to it so their mail client opens pre-filled.
*
* @param {HTMLFormElement} form
* @param {{to?: string, navigate?: (url: string) => void}} [options]
* @returns {string} the mailto: URL that was navigated to
*/
export function handleContactSubmit(
form,
{ to = MAILTO_TARGET, navigate = (url) => { window.location.href = url; } } = {}
) {
const url = buildContactMailto(readFormValues(form), to);
navigate(url);
return url;
}
/**
* Wire the submit handler onto a contact form.
* @param {HTMLFormElement} form
*/
export function initContactForm(form) {
form.addEventListener("submit", (event) => {
event.preventDefault();
handleContactSubmit(form);
});
}
// Browser-only wiring — guarded so this module stays importable in Node tests.
if (typeof window !== "undefined" && typeof document !== "undefined") {
const form = document.getElementById("contact-form");
if (form) initContactForm(form);
}
+15
View File
@@ -0,0 +1,15 @@
/**
* Build a `mailto:` URL from an address, subject, and body.
* Subject and body are URL-encoded so the visitor's mail client
* opens pre-filled with the submitted values.
*
* @param {{to?: string, subject?: string, body?: string}} options
* @returns {string}
*/
export function buildMailtoUrl({ to = "", subject = "", body = "" } = {}) {
const params = new URLSearchParams();
if (subject) params.set("subject", subject);
if (body) params.set("body", body);
const query = params.toString();
return query ? `mailto:${to}?${query}` : `mailto:${to}`;
}
+109
View File
@@ -0,0 +1,109 @@
import test from "node:test";
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
import { dirname, join } from "node:path";
import {
MAILTO_TARGET,
buildContactMailto,
handleContactSubmit,
readFormValues,
} from "../js/contact.js";
const root = join(dirname(fileURLToPath(import.meta.url)), "..");
const contactHtml = readFileSync(join(root, "contact.html"), "utf8");
const indexHtml = readFileSync(join(root, "index.html"), "utf8");
/** Minimal stand-in for a DOM form (has querySelectorAll("[name]")). */
function fakeForm(values) {
return {
querySelectorAll(selector) {
assert.equal(selector, "[name]");
return Object.entries(values).map(([name, value]) => ({ name, value }));
},
};
}
// ---------------------------------------------------------------------------
// Component tests: the contact page renders the three required fields
// ---------------------------------------------------------------------------
test("contact page renders a form with name, email, and message fields", () => {
assert.match(contactHtml, /<form[^>]*id="contact-form"/);
assert.match(contactHtml, /<input[^>]*type="text"[^>]*id="name"[^>]*name="name"/);
assert.match(contactHtml, /<input[^>]*type="email"[^>]*id="email"[^>]*name="email"/);
assert.match(contactHtml, /<textarea[^>]*id="message"[^>]*name="message"/);
});
test("all three contact form fields are required", () => {
assert.match(contactHtml, /<input[^>]*id="name"[^>]*required/);
assert.match(contactHtml, /<input[^>]*id="email"[^>]*required/);
assert.match(contactHtml, /<textarea[^>]*id="message"[^>]*required/);
});
test("contact page loads the module that wires form submission", () => {
assert.match(contactHtml, /<script[^>]*type="module"[^>]*src="js\/contact\.js"/);
});
// ---------------------------------------------------------------------------
// Component tests: the page is linked from site navigation
// ---------------------------------------------------------------------------
test("home page nav links to the contact page", () => {
assert.match(indexHtml, /<a href="contact\.html">Contact<\/a>/);
assert.match(indexHtml, /class="nav-links"/);
});
test("contact page nav marks the contact link as the current page", () => {
assert.match(contactHtml, /<a href="contact\.html"[^>]*aria-current="page"[^>]*>Contact<\/a>/);
});
// ---------------------------------------------------------------------------
// Unit tests: submitting the form opens the mail client pre-filled
// ---------------------------------------------------------------------------
test("buildContactMailto pre-fills subject and body from entered values", () => {
const url = buildContactMailto(
{ name: "Ada Lovelace", email: "ada@example.com", message: "Hi there!" },
"owner@example.com"
);
assert.ok(url.startsWith("mailto:owner@example.com?"));
const params = new URL(url).searchParams;
assert.equal(params.get("subject"), "Contact form submission from Ada Lovelace");
assert.match(params.get("body"), /Name: Ada Lovelace/);
assert.match(params.get("body"), /Email: ada@example\.com/);
assert.match(params.get("body"), /Hi there!/);
});
test("buildContactMailto defaults to the configured mailto target", () => {
const url = buildContactMailto({ name: "A", email: "a@b.c", message: "m" });
assert.ok(url.startsWith(`mailto:${MAILTO_TARGET}?`));
});
test("readFormValues collects name, email, and message", () => {
const values = readFormValues(fakeForm({ name: "N", email: "E", message: "M" }));
assert.deepEqual(values, { name: "N", email: "E", message: "M" });
});
test("handleContactSubmit navigates the visitor to the built mailto URL", () => {
const form = fakeForm({
name: "Grace Hopper",
email: "grace@example.com",
message: "Hello!",
});
const navigated = [];
const url = handleContactSubmit(form, {
to: "owner@example.com",
navigate: (target) => navigated.push(target),
});
assert.equal(navigated.length, 1);
assert.ok(navigated[0].startsWith("mailto:owner@example.com?"));
assert.equal(navigated[0], url);
const params = new URL(navigated[0]).searchParams;
assert.match(params.get("body"), /Grace Hopper/);
});
+33
View File
@@ -0,0 +1,33 @@
import test from "node:test";
import assert from "node:assert/strict";
import { buildMailtoUrl } from "../js/mailto.js";
test("builds a plain mailto URL when there is no subject or body", () => {
assert.equal(buildMailtoUrl({ to: "ada@example.com" }), "mailto:ada@example.com");
});
test("includes subject and body as URL-encoded query parameters", () => {
const url = buildMailtoUrl({
to: "owner@example.com",
subject: "Hello, Ada!",
body: "Line one\nLine two & <more>",
});
assert.ok(url.startsWith("mailto:owner@example.com?"));
const params = new URL(url).searchParams;
assert.equal(params.get("subject"), "Hello, Ada!");
assert.equal(params.get("body"), "Line one\nLine two & <more>");
});
test("omits empty subject and body", () => {
const url = buildMailtoUrl({ to: "owner@example.com", subject: "", body: "" });
assert.equal(url, "mailto:owner@example.com");
});
test("encodes spaces and special characters so the mail client opens pre-filled", () => {
const url = buildMailtoUrl({ to: "o@example.com", subject: "a b", body: "x&y=z" });
const params = new URL(url).searchParams;
assert.equal(params.get("subject"), "a b");
assert.equal(params.get("body"), "x&y=z");
});