diff --git a/Gruntfile.js b/Gruntfile.js index a1f332f..c6c41a3 100644 --- a/Gruntfile.js +++ b/Gruntfile.js @@ -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"]); diff --git a/README.md b/README.md index 8b3bd44..9548507 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/src/main/css/main.css b/src/main/css/main.css index a6719b3..fee9c43 100644 --- a/src/main/css/main.css +++ b/src/main/css/main.css @@ -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"; diff --git a/src/main/css/snippets/common.css b/src/main/css/snippets/common.css index c4c4f77..e313e3a 100644 --- a/src/main/css/snippets/common.css +++ b/src/main/css/snippets/common.css @@ -67,7 +67,7 @@ noscript.fwd-js-notice p { /* Header */ -header a[href="."] { +header.fwd-header a[href="."] { color: black; } diff --git a/src/main/css/snippets/validation.css b/src/main/css/snippets/validation.css new file mode 100644 index 0000000..8c77c1c --- /dev/null +++ b/src/main/css/snippets/validation.css @@ -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; +} diff --git a/src/main/js/Storage.ts b/src/main/js/Storage.ts index 6095907..eb2a1ba 100644 --- a/src/main/js/Storage.ts +++ b/src/main/js/Storage.ts @@ -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 {}; diff --git a/src/main/js/Template.ts b/src/main/js/Template.ts index 7f747ed..0c91e25 100644 --- a/src/main/js/Template.ts +++ b/src/main/js/Template.ts @@ -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 {}; diff --git a/src/main/js/Validation.ts b/src/main/js/Validation.ts new file mode 100644 index 0000000..f7f6889 --- /dev/null +++ b/src/main/js/Validation.ts @@ -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 {}; diff --git a/src/test/index.html b/src/test/index.html index 6abd9d0..95019b4 100644 --- a/src/test/index.html +++ b/src/test/index.html @@ -13,6 +13,7 @@ + Template test | FWDekker @@ -48,6 +49,11 @@ + +

Test

This is an external link in a sentence.

These are some more contents.

@@ -61,20 +67,20 @@

Already have an account? Welcome back!

-
-
+ +
- + - +
@@ -90,15 +96,33 @@ +