Add field + auth-card partials (todo §1); data-driven .field (label/icon/hint/server error) and auth-card shell (head/SSO/body/alt)

This commit is contained in:
2026-06-15 13:16:36 +02:00
parent fcf2abdf17
commit 7716e38d84
7 changed files with 164 additions and 2 deletions

47
src/auth-card.test.ts Normal file
View File

@@ -0,0 +1,47 @@
import assert from "node:assert/strict";
import { dirname, join } from "node:path";
import { test } from "node:test";
import { fileURLToPath } from "node:url";
import * as ejs from "ejs";
const authCard = join(dirname(fileURLToPath(import.meta.url)), "..", "views", "partials", "auth-card.ejs");
const render = (data: Record<string, unknown> = {}): Promise<string> => ejs.renderFile(authCard, data);
const flat = (s: string): string => s.replace(/>\s+</g, "><").replace(/\s+/g, " ").trim();
test("auth-card renders head, SSO providers (text logo + icon link), body slot and alt footer", async () => {
const html = flat(await render({
title: "Sign in", sub: "Welcome back.", action: "/login",
sso: { providers: [
{ label: "Continue with Google", logo: "G" },
{ label: "Continue with SAML SSO", icon: "i-shield", href: "/sso/saml" },
] },
body: '<div id="fields">FORM</div><button class="btn btn-primary btn-block">Sign in</button>',
alt: { text: "Don't have an account?", href: "/register", label: "Create one" },
}));
assert.match(html, /<form class="auth-card" method="post" action="\/login"><div class="auth-head"><h1>Sign in<\/h1><p class="auth-sub">Welcome back\.<\/p><\/div>/);
// SSO: text-logo button vs icon-logo link, then the divider.
assert.match(html, /<div class="sso" aria-label="Single sign-on options"><ul class="sso-list">/);
assert.match(html, /<li><button type="button" class="sso-btn"><span class="sso-logo" aria-hidden="true">G<\/span><span class="sso-label">Continue with Google<\/span><\/button><\/li>/);
assert.match(html, /<li><a class="sso-btn" href="\/sso\/saml"><span class="sso-logo" aria-hidden="true"><svg class="ico ico-sm"><use href="#i-shield"\s*\/?><\/svg><\/span><span class="sso-label">Continue with SAML SSO<\/span><\/a><\/li>/);
assert.match(html, /<\/ul><div class="auth-divider">or<\/div><\/div>/);
// Body slot lands inside .auth-form; alt footer renders text + link.
assert.match(html, /<div class="auth-form"><div id="fields">FORM<\/div><button class="btn btn-primary btn-block">Sign in<\/button><\/div>/);
assert.match(html, /<p class="auth-alt">Don&#39;t have an account\? <a href="\/register">Create one<\/a><\/p><\/form>/); // apostrophe is escaped
});
test("auth-card renders a back link, omits SSO/alt when absent, escapes title, and never throws", async () => {
const back = flat(await render({
title: "Reset password", sub: "Enter your email.",
back: { href: "/login", label: "Back to sign in" },
body: "<button>Send</button>",
}));
assert.match(back, /<div class="auth-head"><a class="auth-back" href="\/login"><svg class="ico ico-sm" aria-hidden="true"><use href="#i-arrow-left"\s*\/?><\/svg>Back to sign in<\/a><h1>Reset password<\/h1>/);
assert.doesNotMatch(back, /class="sso"|auth-alt/);
// Defaults: post method, empty form, escaped title, no throw.
assert.match(flat(await render({ title: "<x>" })), /<form class="auth-card" method="post"><div class="auth-head"><h1>&lt;x&gt;<\/h1><\/div><div class="auth-form"><\/div><\/form>/);
assert.match(flat(await render()), /<form class="auth-card" method="post">/);
});

49
src/field.test.ts Normal file
View File

@@ -0,0 +1,49 @@
import assert from "node:assert/strict";
import { dirname, join } from "node:path";
import { test } from "node:test";
import { fileURLToPath } from "node:url";
import * as ejs from "ejs";
const field = join(dirname(fileURLToPath(import.meta.url)), "..", "views", "partials", "field.ejs");
const render = (data: Record<string, unknown> = {}): Promise<string> => ejs.renderFile(field, data);
const flat = (s: string): string => s.replace(/>\s+</g, "><").replace(/\s+/g, " ").trim();
test("field renders label, icon input, hint, inline link/optional, and a server-driven error", async () => {
// Error field: aria-invalid + aria-describedby wiring, icon, error markup with raw HTML.
const errored = flat(await render({
id: "reg-email", name: "email", label: "Email", type: "email",
autocomplete: "email", placeholder: "you@company.com", required: true, icon: "i-mail",
error: { html: 'Already used. <a href="/login">Sign in</a>.' },
}));
assert.match(errored, /<div class="field has-error"><label for="reg-email">Email<\/label>/);
assert.match(errored, /<div class="input-wrap"><svg class="ico ico-sm input-ico" aria-hidden="true"><use href="#i-mail"\s*\/?><\/svg>/);
assert.match(errored, /<input class="input has-ico" id="reg-email" name="email" type="email" autocomplete="email" placeholder="you@company.com" aria-invalid="true" aria-describedby="reg-email-err" required>/);
assert.match(errored, /<p class="field-error" id="reg-email-err" role="alert"><svg class="ico ico-sm" aria-hidden="true"><use href="#i-alert"\s*\/?><\/svg><span>Already used\. <a href="\/login">Sign in<\/a>\.<\/span><\/p>/);
// field-top with an inline link, hint, minlength; no error → no has-error / aria-invalid.
const withLink = flat(await render({
id: "login-password", name: "password", label: "Password", type: "password",
autocomplete: "current-password", placeholder: "••••••••", required: true, minlength: 8, icon: "i-lock",
link: { href: "/forgot", label: "Forgot password?" }, hint: "Use 8 or more characters.",
}));
assert.match(withLink, /<div class="field"><div class="field-top"><label for="login-password">Password<\/label><a class="field-link" href="\/forgot">Forgot password\?<\/a><\/div>/);
assert.match(withLink, /<input class="input has-ico" id="login-password" name="password" type="password" autocomplete="current-password" placeholder="••••••••" minlength="8" required>/);
assert.match(withLink, /<span class="field-hint">Use 8 or more characters\.<\/span><\/div>/);
assert.doesNotMatch(withLink, /has-error|aria-invalid/);
// field-top with an "Optional" tag, a value, no icon → plain input.
const optional = flat(await render({
id: "reg-name", name: "name", label: "Name", optional: true, value: "Avery", autocomplete: "name", placeholder: "Avery Kline",
}));
assert.match(optional, /<div class="field-top"><label for="reg-name">Name<\/label><span class="optional">Optional<\/span><\/div>/);
assert.match(optional, /<div class="input-wrap"><input class="input" id="reg-name" name="name" type="text" autocomplete="name" placeholder="Avery Kline" value="Avery"><\/div>/);
});
test("field defaults to a bare text input, escapes a string error, and never throws", async () => {
const bare = flat(await render({ id: "x", name: "x", label: "X" }));
assert.match(bare, /<div class="field"><label for="x">X<\/label><div class="input-wrap"><input class="input" id="x" name="x" type="text"><\/div><\/div>/);
const stringErr = flat(await render({ id: "x", name: "x", label: "X", error: "<b>Required</b>." }));
assert.match(stringErr, /<span>&lt;b&gt;Required&lt;\/b&gt;\.<\/span>/); // string error is escaped
assert.match(stringErr, /aria-describedby="x-err"/);
});