Implement validation module

This commit is contained in:
Florine W. Dekker 2022-11-20 20:35:50 +01:00
parent 858e577422
commit 70465ef7bd
Signed by: FWDekker
GPG Key ID: D3DCFAA8A4560BE0
9 changed files with 338 additions and 28 deletions

View File

@ -15,7 +15,7 @@ module.exports = grunt => {
},
focus: {
deploy: {
include: ["css", "storage", "template"],
include: ["css", "storage", "template", "validation"],
},
},
webpack: {
@ -44,7 +44,8 @@ module.exports = grunt => {
module: {
rules: [
{
test: /\.js$/i,
test: /\.ts$/,
use: "ts-loader",
exclude: /node_modules/,
},
],
@ -53,13 +54,31 @@ module.exports = grunt => {
extensions: [".ts"],
},
output: {
library: "fwdekker-template",
libraryTarget: "umd",
filename: "template.js",
path: path.resolve(__dirname, "dist"),
},
mode: "production",
},
validation: {
entry: "./src/main/js/Validation.ts",
module: {
rules: [
{
test: /\.ts$/,
use: "ts-loader",
exclude: /node_modules/,
},
],
},
resolve: {
extensions: [".ts"],
},
output: {
filename: "validation.js",
path: path.resolve(__dirname, "dist/"),
},
mode: "production",
},
},
watch: {
css: {
@ -67,13 +86,17 @@ module.exports = grunt => {
tasks: ["cssmin"],
},
storage: {
files: ["src/main/**/*.ts"],
files: ["src/main/js/Storage.ts"],
tasks: ["webpack:storage"],
},
template: {
files: ["src/main/**/*.js"],
files: ["src/main/js/Template.ts"],
tasks: ["webpack:template"],
},
validation: {
files: ["src/main/js/Validation.ts"],
tasks: ["webpack:validation"],
},
},
});
@ -83,7 +106,7 @@ module.exports = grunt => {
grunt.loadNpmTasks("grunt-focus");
grunt.loadNpmTasks("grunt-webpack");
grunt.registerTask("deploy", ["webpack:storage", "webpack:template", "cssmin"]);
grunt.registerTask("deploy", ["webpack:storage", "webpack:template", "webpack:validation", "cssmin"]);
grunt.registerTask("deploy:server", ["deploy", "focus:deploy"]);
grunt.registerTask("default", ["deploy"]);

View File

@ -8,7 +8,11 @@ The main functionality is provided in `template.js` and `template.css`.
There also exist optional modules for easily reusing common code.
Modules can be used stand-alone.
If `template.js` is used, modules should be loaded after `template.js`.
Currently, the only module is `storage.js` for interfacing with local storage.
Current available modules are:
* `storage.js` for interfacing with local storage, and
Main module optional.
* `validation.js` for form validation.
Requires main module.
## Development

View File

@ -1,5 +1,8 @@
@import "../../../node_modules/@picocss/pico/css/pico.css";
@import "snippets/overrides.css";
@import "snippets/colors.css";
@import "snippets/common.css";
@import "snippets/nav.css";
@import "snippets/overrides.css";
@import "snippets/validation.css";

View File

@ -67,7 +67,7 @@ noscript.fwd-js-notice p {
/* Header */
header a[href="."] {
header.fwd-header a[href="."] {
color: black;
}

View File

@ -0,0 +1,59 @@
.status-card {
font-weight: bold;
}
.status-card .close {
float: right;
display: block;
width: 1rem;
height: 1rem;
margin: 0 0 calc(var(--block-spacing-vertical) / 2) calc(var(--block-spacing-vertical) / 2);
background-image: var(--icon-close);
background-position: center;
background-size: auto 1rem;
background-repeat: no-repeat;
opacity: .5;
transition: opacity var(--transition);
}
.status-card .close:is([aria-current], :hover, :active, :focus) {
opacity: 1
}
.status-card.info {
/* Colors taken from https://isabelcastillo.com/error-info-messages-css */
background-color: #bde5f8;
color: #00529b;
}
.status-card.error {
background-color: var(--form-element-invalid-focus-color);
color: var(--form-element-invalid-border-color);
}
.status-card.success {
background-color: var(--form-element-valid-focus-color);
color: var(--form-element-valid-border-color);
}
.status-card.warning {
/* Colors taken from https://isabelcastillo.com/error-info-messages-css */
background-color: #feefb3;
color: #9f6000;
}
label.invalid,
input.invalid,
*[data-hint-for].invalid {
color: var(--form-element-invalid-border-color) !important;
}
label.valid,
input.valid,
*[data-hint-for].valid {
color: var(--form-element-valid-border-color) !important;
}

View File

@ -181,3 +181,4 @@ export class MemoryStorage implements Storage {
// Export to `window`
(window as any).fwdekker = (window as any).fwdekker ?? {};
(window as any).fwdekker.storage = {Storage, LocalStorage, MemoryStorage};
export {};

View File

@ -12,11 +12,12 @@ function stringToHtml(string: string, query: string): HTMLElement | null {
/**
* Alias for `root.querySelector(query)`.
*
* @param query the query string
* @param query the query string, or `undefined` or `null` if `null` should be returned
* @param root the element to start searching in, or `undefined` if searching should start in `document`
* @returns the element identified by `query` in `root`, or `null` if that element could not be found
*/
function $(query: string, root?: HTMLElement): HTMLElement | null {
function $(query: string | null | undefined, root?: HTMLElement): HTMLElement | null {
if (query == null) return null;
return root === undefined ? document.querySelector(query) : root.querySelector(query);
}
@ -226,19 +227,17 @@ function footerLink(prefix: string, text: string | null | undefined, url: string
* not a valid value as a parameter for that function, its value is considered `undefined`.
*/
doAfterLoad(() => {
const navTargetQuery = getMetaProperty("fwd:nav:target");
if (navTargetQuery != null) {
const navTarget = $(navTargetQuery);
navTarget?.parentElement?.replaceChild(
const navTarget = $(getMetaProperty("fwd:nav:target"));
if (navTarget != null) {
navTarget.parentElement?.replaceChild(
nav(getMetaProperty("fwd:nav:highlight-path") ?? undefined),
navTarget
);
}
const footerTargetQuery = getMetaProperty("fwd:footer:target");
if (footerTargetQuery != null) {
const footerTarget = $(footerTargetQuery);
footerTarget?.parentElement?.replaceChild(
const footerTarget = $(getMetaProperty("fwd:footer:target"));
if (footerTarget != null) {
footerTarget.parentElement?.replaceChild(
footer({
author: getMetaProperty("fwd:footer:author"),
authorURL: getMetaProperty("fwd:footer:author-url"),
@ -256,4 +255,5 @@ doAfterLoad(() => {
// Export to `window`
(window as any).fwdekker = {stringToHtml, $, $a, doAfterLoad};
(window as any).fwdekker = {$, $a, doAfterLoad, getMetaProperty, stringToHtml};
export {};

196
src/main/js/Validation.ts Normal file
View File

@ -0,0 +1,196 @@
if ((window as any).fwdekker == null) throw new Error("Validation module requires main module.");
const {$, $a, doAfterLoad, getMetaProperty} = (window as any).fwdekker;
/**
* Removes all validation-related information from `form`.
*
* @param form the form to hide validation information from
*/
function clearFormValidity(form: HTMLFormElement): void {
clearMessageStatus(form);
$a("input", form).forEach((input: HTMLInputElement) => clearInputValidity(input));
}
/**
* Shows a `type` message in `card`.
*
* @param card the card to show `message` in, or `form` to show the `message` in the form's status card
* @param message the message to show in `card`, or `undefined` if `card` should be hidden
* @param type the type of message to show in `card`, or `undefined` if `card` should be hidden
*/
function showMessageType(card: HTMLElement | HTMLFormElement,
message?: string,
type?: "error" | "info" | "success" | "warning"): void {
if (card instanceof HTMLFormElement) {
card = $(`article[data-status-for="${card.id}"]`);
if (card == null) throw new Error("Could not find status card.");
}
card.classList.remove("hidden", "error", "info", "success", "warning");
if (message == null || type == null) {
card.classList.add("hidden");
$("output", card).innerText = "";
} else {
card.classList.add(type);
$("output", card).innerText = message;
}
}
/**
* Removes the message in `card`, hiding it in the process.
*
* @param card the card to clear the message from
*/
function clearMessageStatus(card: HTMLElement): void {
showMessageType(card);
}
/**
* Shows an error message in `card`.
*
* @param card the card to show `message` in
* @param message the error message to show in `card`
*/
function showMessageError(card: HTMLElement, message: string): void {
showMessageType(card, message, "error");
}
/**
* Shows an information message in `card`.
*
* @param card the card to show `message` in
* @param message the message to show in `card`
*/
function showMessageInfo(card: HTMLElement, message: string): void {
showMessageType(card, message, "info");
}
/**
* Shows a success message in `card`.
*
* @param card the card to show `message` in
* @param message the success message to show in `card`
*/
function showMessageSuccess(card: HTMLElement, message: string): void {
showMessageType(card, message, "success");
}
/**
* Shows a warning message in `card`.
*
* @param card the card to show `message` in
* @param message the success message to show in `card`
*/
function showMessageWarning(card: HTMLElement, message: string): void {
showMessageType(card, message, "warning");
}
/**
* Marks `input` as neither valid nor invalid.
*
* @param input
*/
function clearInputValidity(input: HTMLInputElement): void {
input.classList.remove("valid", "invalid");
input.removeAttribute("aria-invalid");
input.removeAttribute("aria-errormessage");
const label = $(`label[for="${input.id}"]`);
if (label != null)
label.classList.remove("valid", "invalid");
const hint = $(`*[data-hint-for="${input.id}"]`);
if (hint != null) {
hint.classList.remove("valid", "invalid");
hint.role = null;
hint.innerText = hint.dataset["hint"] ?? "";
}
}
/**
* Shows to the user that `input` is invalid.
*
* @param input the input to show as invalid
* @param message the message explaining what is invalid
*/
function showInputInvalid(input: HTMLInputElement, message?: string): void {
clearInputValidity(input);
input.classList.add("invalid");
input.setAttribute("aria-invalid", "true");
input.focus();
const label = $(`label[for="${input.id}"]`);
if (label != null)
label.classList.add("invalid");
const hint = $(`*[data-hint-for="${input.id}"]`);
if (hint != null && message != null) {
hint.classList.add("invalid");
input.setAttribute("aria-errormessage", hint.id);
hint.role = "alert";
hint.innerText = message;
}
}
/**
* Shows to the user that `input` is valid.
*
* @param input the input to show as valid
* @param message the message to show at the input
*/
function showInputValid(input: HTMLInputElement, message?: string): void {
clearInputValidity(input);
input.classList.add("valid");
input.setAttribute("aria-invalid", "false");
const label = $(`label[for="${input.id}"]`);
if (label != null)
label.classList.add("valid");
const hint = $(`*[data-hint-for="${input.id}"]`);
if (hint != null) {
hint.classList.add("valid");
if (message != null) hint.innerText = message;
}
}
/**
* If the `fwd:validation:load-hints` meta property has been set, loads hints and implements close buttons for forms.
*/
doAfterLoad(() => {
if (getMetaProperty("fwd:validation:load-forms") === undefined) return;
$a(".status-card .close").forEach((close: HTMLElement) => {
close.addEventListener("click", (event: MouseEvent) => {
event.preventDefault();
close.parentElement!.classList.add("hidden");
});
});
$a("input + small[data-hint]").forEach((hint: Element) => {
if (!(hint instanceof HTMLElement)) return;
hint.innerText = hint.dataset["hint"] ?? "";
});
});
// Export to `window`
(window as any).fwdekker = (window as any).fwdekker ?? {};
(window as any).fwdekker.validation = {
clearFormValidity,
clearMessageStatus, showMessageError, showMessageInfo, showMessageSuccess, showMessageWarning,
clearInputValidity, showInputInvalid, showInputValid
};
export {};

View File

@ -13,6 +13,7 @@
<meta name="fwd:footer:target" content="#footer" />
<meta name="fwd:footer:vcs-url" content="https://git.fwdekker.com/FWDekker/fwdekker-template/" />
<meta name="fwd:footer:version" content="vTEST" />
<meta name="fwd:validation:load-forms" />
<title>Template test | FWDekker</title>
@ -48,6 +49,11 @@
</hgroup>
</header>
<article id="global-status-card" class="status-card hidden">
<output></output>
<a class="close" href="#" aria-label="Close"></a>
</article>
<h1>Test</h1>
<p>This <a href="./" target="_blank">is an external link</a> in a sentence.</p>
<p>These are some more contents.</p>
@ -61,20 +67,20 @@
<h3>Already have an account? Welcome back!</h3>
</hgroup>
</header>
<form id="login-form" novalidate>
<article class="form-validation-info">
<form id="test-form" novalidate>
<article class="status-card hidden" data-status-for="test-form">
<output>Congrats!</output>
<button type="button" class="close-button">&times;</button>
<a class="close" href="#" aria-label="Close"></a>
</article>
<fieldset>
<label for="test-email">Email</label>
<input id="test-email" type="email" name="email" autocomplete="on" />
<small data-for="test-email" data-hint></small>
<small id="test-email-hint" data-hint-for="test-email" data-hint="Test hint"></small>
<label for="test-password">Password</label>
<input id="test-password" type="password" name="password" />
<small data-for="login-password" data-hint></small>
<small id="test-password-hint" data-hint-for="test-password"></small>
</fieldset>
<fieldset>
@ -90,15 +96,33 @@
<!--suppress HtmlUnknownTarget -->
<script src="../../dist/template.js"></script>
<script src="../../dist/storage.js"></script>
<script src="../../dist/validation.js"></script>
<script>
const {$} = window.fwdekker;
const storage = new window.fwdekker.storage.MemoryStorage();
storage.setNumber("test-key", 11);
console.log("Expected: 11. Actual: " + storage.getNumber("test-key", 0) + ".");
console.log($("#footer"));
const validation = window.fwdekker.validation;
const testForm = $("#test-form");
testForm.addEventListener("submit", (event) => {
event.preventDefault();
validation.clearFormValidity(testForm);
const emailInput = $("#test-email");
if (emailInput.value.includes("@")) {
validation.showInputValid(emailInput);
validation.showMessageSuccess(testForm, "Yay!");
} else if (emailInput.value.trim() !== "") {
validation.showInputInvalid(emailInput, "Enter a valid email address.");
validation.showMessageError(testForm, "Oh no!");
}
});
validation.showMessageWarning($("#global-status-card"), ":D");
</script>
</body>
</html>