Compare commits
74 Commits
Author | SHA1 | Date |
---|---|---|
Florine W. Dekker | 164a76d9fd | |
Florine W. Dekker | a6faa1ebe8 | |
Florine W. Dekker | a1d3384e32 | |
Florine W. Dekker | eb12f0473a | |
Florine W. Dekker | 09cacd241f | |
Florine W. Dekker | c495714867 | |
Florine W. Dekker | 080c1f852c | |
Florine W. Dekker | 7ecd9ec165 | |
Florine W. Dekker | e5e6a0d347 | |
Florine W. Dekker | cba23b4911 | |
Florine W. Dekker | 46da788e86 | |
Florine W. Dekker | 0ec60b3480 | |
Florine W. Dekker | 7648e49999 | |
Florine W. Dekker | 201efeca8c | |
Florine W. Dekker | c8c99373a3 | |
Florine W. Dekker | 9220ac6224 | |
Florine W. Dekker | 6c8215bc0c | |
Florine W. Dekker | 0be8fc9b97 | |
Florine W. Dekker | c71fab3342 | |
Florine W. Dekker | a208f13f4e | |
Florine W. Dekker | 2e33b8d762 | |
Florine W. Dekker | eb83cfa97c | |
Florine W. Dekker | dc50ad5859 | |
Florine W. Dekker | 570e9c5764 | |
Florine W. Dekker | 4d0f1dc5b4 | |
Florine W. Dekker | df1198b361 | |
Florine W. Dekker | 4aa27c567a | |
Florine W. Dekker | 2d46bf1969 | |
Florine W. Dekker | 537d8db0e2 | |
Florine W. Dekker | b97afae983 | |
Florine W. Dekker | ae202b072b | |
Florine W. Dekker | 64eb21dfb8 | |
Florine W. Dekker | 24443ac455 | |
Florine W. Dekker | 107962a83d | |
Florine W. Dekker | 527b52069b | |
Florine W. Dekker | febc5d4189 | |
Florine W. Dekker | 6f15754c50 | |
Florine W. Dekker | 9beda27c23 | |
Florine W. Dekker | 70465ef7bd | |
Florine W. Dekker | 858e577422 | |
Florine W. Dekker | bf4aa0289b | |
Florine W. Dekker | 723974d103 | |
Florine W. Dekker | 239be7235f | |
Florine W. Dekker | a35ca5efc6 | |
Florine W. Dekker | 60c84bd48e | |
Florine W. Dekker | 80c6437b11 | |
Florine W. Dekker | 6965a9d20e | |
Florine W. Dekker | 8d5b6ef101 | |
Florine W. Dekker | 04095848c2 | |
Florine W. Dekker | 2702e00880 | |
Florine W. Dekker | 11f66473f3 | |
Florine W. Dekker | a5f43c6436 | |
Florine W. Dekker | 15c0485527 | |
Florine W. Dekker | c44e97cdbd | |
Florine W. Dekker | f488517186 | |
Florine W. Dekker | bf1bfdc079 | |
Florine W. Dekker | 9592715507 | |
Florine W. Dekker | 13dcdd8237 | |
Florine W. Dekker | 55dd99512f | |
Florine W. Dekker | de1832b736 | |
Florine W. Dekker | 8cae4df5e4 | |
Florine W. Dekker | 05177b4e39 | |
Florine W. Dekker | 7e289927de | |
Florine W. Dekker | 1f0a7df0db | |
Florine W. Dekker | 39cfcb51a5 | |
Florine W. Dekker | e51c026eca | |
Florine W. Dekker | 161a36b308 | |
Florine W. Dekker | b756a2cc44 | |
Florine W. Dekker | db7ab52818 | |
Florine W. Dekker | 70bbc76aa2 | |
Florine W. Dekker | cbcb30aba0 | |
Florine W. Dekker | 9a3227b43f | |
Florine W. Dekker | 4f9d7b8311 | |
Florine W. Dekker | b5be385ea5 |
67
Gruntfile.js
67
Gruntfile.js
|
@ -15,36 +15,7 @@ module.exports = grunt => {
|
|||
},
|
||||
focus: {
|
||||
dev: {
|
||||
include: ["css", "js"],
|
||||
},
|
||||
},
|
||||
webpack: {
|
||||
options: {
|
||||
entry: "./src/main/js/main.js",
|
||||
module: {
|
||||
rules: [
|
||||
{
|
||||
test: /\.js$/i,
|
||||
exclude: /node_modules/,
|
||||
},
|
||||
],
|
||||
},
|
||||
resolve: {
|
||||
extensions: [".js"],
|
||||
},
|
||||
output: {
|
||||
library: "fwdekker-template",
|
||||
libraryTarget: "umd",
|
||||
filename: "template.js",
|
||||
path: path.resolve(__dirname, "dist"),
|
||||
}
|
||||
},
|
||||
dev: {
|
||||
mode: "development",
|
||||
devtool: "inline-source-map",
|
||||
},
|
||||
deploy: {
|
||||
mode: "production",
|
||||
include: ["css", "ts"],
|
||||
},
|
||||
},
|
||||
watch: {
|
||||
|
@ -52,11 +23,39 @@ module.exports = grunt => {
|
|||
files: ["src/main/**/*.css"],
|
||||
tasks: ["cssmin"],
|
||||
},
|
||||
js: {
|
||||
files: ["src/main/**/*.js"],
|
||||
ts: {
|
||||
files: ["src/main/**/*.ts"],
|
||||
tasks: ["webpack:dev"],
|
||||
},
|
||||
},
|
||||
webpack: {
|
||||
options: {
|
||||
entry: "./src/main/js/Main.ts",
|
||||
module: {
|
||||
rules: [
|
||||
{
|
||||
test: /\.ts$/,
|
||||
use: "ts-loader",
|
||||
exclude: /node_modules/,
|
||||
},
|
||||
],
|
||||
},
|
||||
resolve: {
|
||||
extensions: [".ts"],
|
||||
},
|
||||
output: {
|
||||
filename: "template.js",
|
||||
path: path.resolve(__dirname, "dist/"),
|
||||
},
|
||||
},
|
||||
dev: {
|
||||
mode: "development",
|
||||
devtool: "inline-source-map",
|
||||
},
|
||||
deploy: {
|
||||
mode: "production",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
grunt.loadNpmTasks("grunt-contrib-clean");
|
||||
|
@ -65,9 +64,9 @@ module.exports = grunt => {
|
|||
grunt.loadNpmTasks("grunt-focus");
|
||||
grunt.loadNpmTasks("grunt-webpack");
|
||||
|
||||
grunt.registerTask("dev", ["webpack:dev", "cssmin"]);
|
||||
grunt.registerTask("dev", ["clean", "webpack:dev", "cssmin"]);
|
||||
grunt.registerTask("dev:server", ["dev", "focus:dev"]);
|
||||
grunt.registerTask("deploy", ["webpack:deploy", "cssmin"]);
|
||||
grunt.registerTask("deploy", ["clean", "webpack:deploy", "cssmin"]);
|
||||
|
||||
grunt.registerTask("default", ["dev"]);
|
||||
};
|
||||
|
|
2
LICENSE
2
LICENSE
|
@ -1,6 +1,6 @@
|
|||
MIT License
|
||||
|
||||
Copyright (c) 2020 Felix W. Dekker
|
||||
Copyright (c) 2020 Florine W. Dekker
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
|
|
22
README.md
22
README.md
|
@ -1,8 +1,20 @@
|
|||
# FWDekker Template
|
||||
The base template for pages on fwdekker.com.
|
||||
|
||||
This module contains templating functions (e.g. `header`, `footer`), CSS libraries, and some common utility methods that
|
||||
are used on nearly all pages anyway.
|
||||
Contains utilities for
|
||||
* settings up header, footer, and navigation,
|
||||
* interacting with local storage (and an in-memory variant for testing), and
|
||||
* form validation.
|
||||
|
||||
Simply import `template.js` and `template.css` to get it working.
|
||||
All JavaScript functionalities are exposed using the `window.fwdekker` object.
|
||||
|
||||
Some functionalities are automatically executed after configuring some `<meta>` tags in the HTML.
|
||||
For example, set `<meta name="fwd:nav:target" content="#nav" />` to automatically put the navigation bar in the `#nav`
|
||||
element.
|
||||
All meta-tag behaviour is opt-in.
|
||||
|
||||
Read the files' individual documentation for more information.
|
||||
|
||||
|
||||
## Development
|
||||
|
@ -17,10 +29,10 @@ $> npm ci
|
|||
|
||||
### Building
|
||||
```shell script
|
||||
# Build the template in `dist/` for development
|
||||
# Build the tool in `dist/` for development
|
||||
$> npm run dev
|
||||
# Run the `dev` task and automatically rerun it whenever files are changed
|
||||
# Same as above, but automatically rerun it whenever files are changed
|
||||
$> npm run dev:server
|
||||
# Build the template in `dist/` for deployment
|
||||
# Build the tool in `dist/` for deployment
|
||||
$> npm run deploy
|
||||
```
|
||||
|
|
Binary file not shown.
30
package.json
30
package.json
|
@ -1,16 +1,16 @@
|
|||
{
|
||||
"name": "@fwdekker/template",
|
||||
"version": "2.2.2",
|
||||
"version": "3.6.5",
|
||||
"description": "The base template for pages on fwdekker.com.",
|
||||
"author": "Felix W. Dekker",
|
||||
"author": "Florine W. Dekker",
|
||||
"license": "MIT",
|
||||
"homepage": "https://git.fwdekker.com/FWDekker/fwdekker-template",
|
||||
"homepage": "https://git.fwdekker.com/fwdekker.com/template",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git@git.fwdekker.com:FWDekker/fwdekker-template.git"
|
||||
"url": "git@git.fwdekker.com:fwdekker.com/template.git"
|
||||
},
|
||||
"bugs": {
|
||||
"url": "https://git.fwdekker.com/FWDekker/fwdekker-template/issues"
|
||||
"url": "https://git.fwdekker.com/fwdekker.com/template/issues"
|
||||
},
|
||||
"browser": "template.js",
|
||||
"files": [
|
||||
|
@ -24,18 +24,20 @@
|
|||
"deploy": "grunt deploy"
|
||||
},
|
||||
"dependencies": {
|
||||
"milligram": "^1.4.1",
|
||||
"normalize.css": "^8.0.1"
|
||||
"@picocss/pico": "^1.5.10"
|
||||
},
|
||||
"devDependencies": {
|
||||
"grunt": "^1.4.0",
|
||||
"grunt-cli": "^1.4.2",
|
||||
"grunt-contrib-clean": "^2.0.0",
|
||||
"grunt-contrib-cssmin": "^4.0.0",
|
||||
"grunt": "^1.6.1",
|
||||
"grunt-cli": "^1.4.3",
|
||||
"grunt-contrib-clean": "^2.0.1",
|
||||
"grunt-contrib-cssmin": "^5.0.0",
|
||||
"grunt-contrib-watch": "^1.1.0",
|
||||
"grunt-focus": "^1.0.0",
|
||||
"grunt-webpack": "^4.0.3",
|
||||
"webpack": "^5.35.0",
|
||||
"webpack-cli": "^4.6.0"
|
||||
"grunt-webpack": "^6.0.0",
|
||||
"ts-loader": "^9.5.1",
|
||||
"ts-node": "^10.9.1",
|
||||
"typescript": "^5.3.2",
|
||||
"webpack": "^5.89.0",
|
||||
"webpack-cli": "^5.1.4"
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,6 +1,8 @@
|
|||
@import "../../../node_modules/normalize.css/normalize.css";
|
||||
@import "../../../node_modules/milligram/dist/milligram.css";
|
||||
@import "snippets/colors.css";
|
||||
@import "snippets/common.css";
|
||||
@import "snippets/nav.css";
|
||||
@import "../../../node_modules/@picocss/pico/css/pico.css";
|
||||
@import "snippets/overrides.css";
|
||||
|
||||
@import "snippets/colors.css";
|
||||
|
||||
@import "snippets/common.css";
|
||||
@import "snippets/forms.css";
|
||||
@import "snippets/nav.css";
|
||||
|
|
|
@ -1,7 +1,41 @@
|
|||
:root {
|
||||
--fwdekker-theme-color: #0033cc;
|
||||
--fwdekker-theme-color-dark: #0029a3;
|
||||
--fwdekker-theme-color-very-dark: #001f7a;
|
||||
--fwdekker-theme-color-light: #003df5;
|
||||
--fwdekker-theme-color-very-light: #1f57ff;
|
||||
/* pico.css overrides, based on https://picocss.com/docs/customization.html */
|
||||
|
||||
/* Light (default) */
|
||||
.fwd-nav,
|
||||
[data-theme="light"],
|
||||
:root:not([data-theme="dark"]) {
|
||||
--primary: rgb(0, 51, 204) !important;
|
||||
--primary-hover: rgb(0, 61, 245) !important;
|
||||
--primary-focus: rgba(0, 41, 163, 0.125) !important;
|
||||
--primary-focus-opaque: rgb(0, 41, 163) !important;
|
||||
--primary-focus-dark: rgb(0, 29, 114) !important;
|
||||
--primary-inverse: white !important;
|
||||
}
|
||||
|
||||
/* Dark (auto) */
|
||||
@media only screen and (prefers-color-scheme: dark) {
|
||||
:root:not([data-theme="light"]):not(.fwd-nav) {
|
||||
--primary: #1e88e5 !important;
|
||||
--primary-hover: #2196f3 !important;
|
||||
--primary-focus: rgba(30, 136, 229, 0.25) !important;
|
||||
--primary-focus-opaque: rgb(30, 136, 229) !important;
|
||||
--primary-inverse: white !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* Dark (forced) */
|
||||
:root[data-theme="dark"]:not(.fwd-nav) {
|
||||
--primary: #1e88e5 !important;
|
||||
--primary-hover: #2196f3 !important;
|
||||
--primary-focus: rgba(30, 136, 229, 0.25) !important;
|
||||
--primary-focus-opaque: rgb(30, 136, 229) !important;
|
||||
--primary-inverse: white !important;
|
||||
}
|
||||
|
||||
/* Common */
|
||||
:root {
|
||||
--form-element-active-border-color: var(--primary) !important;
|
||||
--form-element-focus-color: var(--primary-focus) !important;
|
||||
--switch-color: var(--primary-inverse) !important;
|
||||
--switch-checked-background-color: var(--primary) !important;
|
||||
}
|
||||
|
|
|
@ -1,60 +1,90 @@
|
|||
/* Base elements */
|
||||
html, body {
|
||||
height: 100%;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
/* External link icon */
|
||||
a[target="_blank"]::after {
|
||||
display: inline-block;
|
||||
|
||||
width: 0.7em;
|
||||
height: 0.7em;
|
||||
margin-left: 0.25rem;
|
||||
|
||||
/* Image from https://icons.getbootstrap.com/icons/box-arrow-up-right/. MIT License. */
|
||||
--mask-image: url("data:image/svg+xml,%3C%3Fxml version='1.0' encoding='UTF-8'%3F%3E%3Csvg fill='currentColor' viewBox='0 0 16 16' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M8.636 3.5a.5.5 0 0 0-.5-.5H1.5A1.5 1.5 0 0 0 0 4.5v10A1.5 1.5 0 0 0 1.5 16h10a1.5 1.5 0 0 0 1.5-1.5V7.864a.5.5 0 0 0-1 0V14.5a.5.5 0 0 1-.5.5h-10a.5.5 0 0 1-.5-.5v-10a.5.5 0 0 1 .5-.5h6.636a.5.5 0 0 0 .5-.5z' fill-rule='evenodd'/%3E%3Cpath d='M16 .5a.5.5 0 0 0-.5-.5h-5a.5.5 0 0 0 0 1h3.793L6.146 9.146a.5.5 0 1 0 .708.708L15 1.707V5.5a.5.5 0 0 0 1 0v-5z' fill-rule='evenodd'/%3E%3C/svg%3E%0A");
|
||||
-webkit-mask-image: var(--mask-image);
|
||||
-webkit-mask-size: cover;
|
||||
mask-image: var(--mask-image);
|
||||
mask-size: cover;
|
||||
background-repeat: no-repeat no-repeat;
|
||||
background-position: center center;
|
||||
background-size: cover;
|
||||
background-color: var(--primary);
|
||||
|
||||
content: "";
|
||||
}
|
||||
|
||||
body {
|
||||
color: black;
|
||||
}
|
||||
|
||||
#contents {
|
||||
margin-top: 5rem;
|
||||
margin-bottom: 5rem;
|
||||
}
|
||||
|
||||
noscript p {
|
||||
color: red;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
/* Hide anything */
|
||||
.hidden {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
|
||||
/* Main element display state */
|
||||
main.pageVisible {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 100%;
|
||||
@media (min-width: 576px) {
|
||||
.hidden-no-mobile {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.pageContents.pageVisible {
|
||||
flex: 1;
|
||||
@media (max-width: 576px) {
|
||||
.hidden-on-mobile {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* Container with sidebar, e.g. for table of contents */
|
||||
@media (min-width: 992px) {
|
||||
:root {
|
||||
--aside-width: 200px;
|
||||
}
|
||||
|
||||
.grid-with-sidebar {
|
||||
display: grid;
|
||||
grid-column-gap: calc(var(--block-spacing-horizontal) * 3);
|
||||
grid-template-columns: var(--aside-width) auto;
|
||||
}
|
||||
|
||||
.grid-with-sidebar aside {
|
||||
max-width: var(--aside-width);
|
||||
}
|
||||
|
||||
.grid-with-sidebar aside .sticky {
|
||||
position: sticky;
|
||||
top: var(--block-spacing-vertical);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 992px) {
|
||||
.grid-with-sidebar aside {
|
||||
margin-bottom: var(--block-spacing-vertical);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/* Header */
|
||||
header .container {
|
||||
/* Noscript */
|
||||
noscript.fwd-js-notice img {
|
||||
position: absolute;
|
||||
}
|
||||
|
||||
noscript.fwd-js-notice p {
|
||||
font-weight: bold;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
|
||||
/* Footer */
|
||||
footer {
|
||||
margin-bottom: 3rem;
|
||||
/* Header */
|
||||
header.fwd-header a[href="."] {
|
||||
color: unset;
|
||||
}
|
||||
|
||||
#footerVersion {
|
||||
|
||||
/* Footer */
|
||||
footer.fwd-footer #fwd-footer-version {
|
||||
float: right;
|
||||
}
|
||||
|
||||
|
||||
/* Make arrow next to dropdown visible */
|
||||
select {
|
||||
-webkit-appearance: menulist;
|
||||
-moz-appearance: menulist;
|
||||
appearance: auto;
|
||||
}
|
||||
|
|
|
@ -0,0 +1,87 @@
|
|||
/* Status card */
|
||||
.status-card {
|
||||
font-weight: bold;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.status-card output {
|
||||
display: block;
|
||||
margin-right: var(--block-spacing-horizontal);
|
||||
}
|
||||
|
||||
.status-card .close {
|
||||
position: absolute;
|
||||
right: var(--block-spacing-horizontal);
|
||||
top: calc(var(--block-spacing-vertical) / 2);
|
||||
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
|
||||
/* Input validation */
|
||||
label.invalid,
|
||||
*[data-label-for].invalid,
|
||||
input.invalid,
|
||||
*[data-hint-for].invalid {
|
||||
color: var(--form-element-invalid-border-color) !important;
|
||||
}
|
||||
|
||||
label.valid,
|
||||
*[data-label-for].valid,
|
||||
input.valid,
|
||||
*[data-hint-for].valid {
|
||||
color: var(--form-element-valid-border-color) !important;
|
||||
}
|
||||
|
||||
|
||||
/* Enable hint-like styling on any element */
|
||||
.input-hint {
|
||||
display: block;
|
||||
|
||||
/*noinspection CssUnresolvedCustomProperty*/
|
||||
margin-top: calc(var(--spacing) * -.75);
|
||||
}
|
||||
|
||||
|
||||
/* Custom components */
|
||||
.inline-button {
|
||||
display: inline-block;
|
||||
width: unset;
|
||||
margin: 0;
|
||||
padding: 0.3em;
|
||||
}
|
|
@ -1,119 +1,138 @@
|
|||
/* Base elements */
|
||||
nav {
|
||||
margin: 0;
|
||||
width: 100%;
|
||||
|
||||
background-color: var(--fwdekker-theme-color);
|
||||
border-bottom: 1px solid #cccccc;
|
||||
font-size: 120%;
|
||||
|
||||
--padding: calc(2em / 3);
|
||||
nav.fwd-nav {
|
||||
--fwd-nav-box-shadow: 0px 0px 10px 0px rgba(0, 0, 0, 0.75);
|
||||
box-shadow: var(--fwd-nav-box-shadow);
|
||||
}
|
||||
|
||||
nav * {
|
||||
z-index: 10;
|
||||
vertical-align: middle;
|
||||
nav.fwd-nav > ul {
|
||||
display: flex;
|
||||
align-items: start;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
nav a, nav a:link, nav a:visited, nav a:hover, nav a:active, nav #nav-hamburger-label {
|
||||
display: inline-block;
|
||||
margin: 0;
|
||||
padding: calc(var(--padding)) calc(var(--padding));
|
||||
height: 100%;
|
||||
|
||||
color: white;
|
||||
nav.fwd-nav > ul > li {
|
||||
flex-grow: 0;
|
||||
flex-basis: 0;
|
||||
}
|
||||
|
||||
nav #nav-hamburger-label {
|
||||
float: right;
|
||||
font-size: unset;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
|
||||
/* Logo */
|
||||
nav .logo {
|
||||
width: calc(1em + var(--padding));
|
||||
height: calc(1em + var(--padding));
|
||||
|
||||
vertical-align: middle;
|
||||
filter: brightness(0) invert(1);
|
||||
}
|
||||
|
||||
nav div.logo {
|
||||
display: inline-block;
|
||||
margin-right: calc(1em / 3);
|
||||
}
|
||||
|
||||
|
||||
/* First level nesting */
|
||||
nav ul {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
nav ul li {
|
||||
display: inline-block;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
|
||||
nav.fwd-nav li {
|
||||
position: relative;
|
||||
background-color: var(--fwdekker-theme-color);
|
||||
width: 100%;
|
||||
padding: var(--nav-element-spacing-horizontal);
|
||||
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
nav ul li:hover,
|
||||
nav ul li:focus-within,
|
||||
nav #nav-hamburger-label:hover,
|
||||
nav #nav-hamburger-label:focus-within {
|
||||
cursor: pointer;
|
||||
background-color: var(--fwdekker-theme-color-very-dark);
|
||||
nav.fwd-nav li > :first-child {
|
||||
display: inline-block;
|
||||
width: 100%;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
nav li.currentPage {
|
||||
background-color: var(--fwdekker-theme-color-dark);
|
||||
}
|
||||
|
||||
|
||||
/* Second level nesting */
|
||||
nav ul li ul {
|
||||
nav.fwd-nav ul ul {
|
||||
display: none;
|
||||
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
left: 0;
|
||||
margin: 0;
|
||||
|
||||
box-shadow: var(--fwd-nav-box-shadow);
|
||||
}
|
||||
|
||||
nav ul li ul li ul {
|
||||
nav.fwd-nav li:where(:active, :focus-within, :hover, .fwd-nav-active) > ul {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
nav.fwd-nav ul ul ul {
|
||||
left: 100%;
|
||||
top: 0;
|
||||
}
|
||||
|
||||
nav ul li:hover > ul,
|
||||
nav ul li:focus-within > ul,
|
||||
nav ul li ul:hover {
|
||||
display: block;
|
||||
/* z-index */
|
||||
nav.fwd-nav a {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
nav ul li ul li {
|
||||
min-width: 7em;
|
||||
width: 100%;
|
||||
white-space: nowrap;
|
||||
nav.fwd-nav > ul > li > ul {
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
|
||||
/* Hide hamburger-related elements */
|
||||
nav input[type="checkbox"] {
|
||||
display: none;
|
||||
/* Colors and optional styling */
|
||||
nav.fwd-nav,
|
||||
nav.fwd-nav ul {
|
||||
background-color: var(--primary);
|
||||
}
|
||||
|
||||
@media (min-width: 600px) {
|
||||
nav #nav-hamburger-label {
|
||||
nav.fwd-nav li.fwd-nav-separator {
|
||||
border-top: 1px solid #ccc;
|
||||
}
|
||||
|
||||
nav.fwd-nav li:where(:active, :focus-within, :hover, .fwd-nav-active) {
|
||||
background-color: var(--primary-focus-dark);
|
||||
}
|
||||
|
||||
nav.fwd-nav li.fwd-nav-highlighted:not(:where(:active, :focus-within, :hover, .fwd-nav-active)) {
|
||||
background-color: var(--primary-focus-opaque);
|
||||
}
|
||||
|
||||
nav.fwd-nav a {
|
||||
color: var(--primary-inverse);
|
||||
}
|
||||
|
||||
nav.fwd-nav a::after {
|
||||
background-color: var(--primary-inverse);
|
||||
}
|
||||
|
||||
/* Logo */
|
||||
nav.fwd-nav #logo {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
nav.fwd-nav #logo::before {
|
||||
display: inline-block;
|
||||
|
||||
width: calc(1em * var(--line-height));
|
||||
height: calc(1em * var(--line-height));
|
||||
margin-right: 0.25rem;
|
||||
vertical-align: top;
|
||||
|
||||
--mask-image: url("https://fwdekker.com/favicon.png");
|
||||
-webkit-mask-image: var(--mask-image);
|
||||
-webkit-mask-size: cover;
|
||||
mask-image: var(--mask-image);
|
||||
mask-size: cover;
|
||||
background-repeat: no-repeat no-repeat;
|
||||
background-position: center center;
|
||||
background-size: cover;
|
||||
background-color: var(--primary-inverse);
|
||||
|
||||
content: "";
|
||||
}
|
||||
|
||||
/* Hamburger */
|
||||
nav.fwd-nav #fwd-nav-hamburger-label {
|
||||
height: fit-content;
|
||||
margin: 0;
|
||||
padding: calc(var(--nav-element-spacing-horizontal) + var(--nav-link-spacing-vertical));
|
||||
|
||||
color: var(--primary-inverse);
|
||||
}
|
||||
|
||||
nav.fwd-nav #fwd-nav-hamburger-label:where(:active, :focus-within, :hover, .fwd-nav-active) {
|
||||
background-color: var(--primary-focus-dark);
|
||||
}
|
||||
|
||||
@media (min-width: 576px) {
|
||||
nav.fwd-nav #fwd-nav-hamburger-label {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
nav input[type="checkbox"]:not(:checked) ~ ul li:not(:first-child) {
|
||||
@media (max-width: 576px) {
|
||||
nav.fwd-nav #fwd-nav-hamburger-checkbox:not(:checked) ~ ul li:not(:first-child) {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,90 +1,95 @@
|
|||
/* Override Milligram color scheme */
|
||||
/* Replaces #9b4dca with #0033cc */
|
||||
.button,
|
||||
button,
|
||||
input[type='button'],
|
||||
input[type='reset'],
|
||||
input[type='submit'] {
|
||||
background-color: var(--fwdekker-theme-color);
|
||||
border: 0.1rem solid var(--fwdekker-theme-color);
|
||||
/* pico.css: Improved text contrast (see also picocss/pico#276) */
|
||||
:root {
|
||||
--code-color: var(--color) !important;
|
||||
}
|
||||
|
||||
.button[disabled]:focus, .button[disabled]:hover,
|
||||
button[disabled]:focus,
|
||||
button[disabled]:hover,
|
||||
input[type='button'][disabled]:focus,
|
||||
input[type='button'][disabled]:hover,
|
||||
input[type='reset'][disabled]:focus,
|
||||
input[type='reset'][disabled]:hover,
|
||||
input[type='submit'][disabled]:focus,
|
||||
input[type='submit'][disabled]:hover {
|
||||
background-color: var(--fwdekker-theme-color);
|
||||
border-color: var(--fwdekker-theme-color);
|
||||
:root[data-theme="light"] {
|
||||
--muted-color: hsl(205deg, 15%, 41%) !important;
|
||||
}
|
||||
|
||||
.button.button-outline,
|
||||
button.button-outline,
|
||||
input[type='button'].button-outline,
|
||||
input[type='reset'].button-outline,
|
||||
input[type='submit'].button-outline {
|
||||
color: var(--fwdekker-theme-color);
|
||||
:root[data-theme="dark"] {
|
||||
--muted-color: hsl(205deg, 12%, 59%) !important;
|
||||
}
|
||||
|
||||
.button.button-outline[disabled]:focus, .button.button-outline[disabled]:hover,
|
||||
button.button-outline[disabled]:focus,
|
||||
button.button-outline[disabled]:hover,
|
||||
input[type='button'].button-outline[disabled]:focus,
|
||||
input[type='button'].button-outline[disabled]:hover,
|
||||
input[type='reset'].button-outline[disabled]:focus,
|
||||
input[type='reset'].button-outline[disabled]:hover,
|
||||
input[type='submit'].button-outline[disabled]:focus,
|
||||
input[type='submit'].button-outline[disabled]:hover {
|
||||
color: var(--fwdekker-theme-color);
|
||||
/* pico.css: Bold <label> and <th>, except for checkbox/radio labels */
|
||||
:root {
|
||||
--form-label-font-weight: bold;
|
||||
}
|
||||
|
||||
.button.button-clear,
|
||||
button.button-clear,
|
||||
input[type='button'].button-clear,
|
||||
input[type='reset'].button-clear,
|
||||
input[type='submit'].button-clear {
|
||||
color: var(--fwdekker-theme-color);
|
||||
tr th {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.button.button-clear[disabled]:focus, .button.button-clear[disabled]:hover,
|
||||
button.button-clear[disabled]:focus,
|
||||
button.button-clear[disabled]:hover,
|
||||
input[type='button'].button-clear[disabled]:focus,
|
||||
input[type='button'].button-clear[disabled]:hover,
|
||||
input[type='reset'].button-clear[disabled]:focus,
|
||||
input[type='reset'].button-clear[disabled]:hover,
|
||||
input[type='submit'].button-clear[disabled]:focus,
|
||||
input[type='submit'].button-clear[disabled]:hover {
|
||||
color: var(--fwdekker-theme-color);
|
||||
input:where([type="checkbox"], [type="radio"]) + label {
|
||||
font-weight: normal;
|
||||
}
|
||||
|
||||
pre {
|
||||
border-left: 0.3rem solid var(--fwdekker-theme-color);
|
||||
/* pico.css: Halve header margins */
|
||||
h1 {
|
||||
--typography-spacing-vertical: 1.5rem;
|
||||
}
|
||||
|
||||
input[type='email']:focus,
|
||||
input[type='number']:focus,
|
||||
input[type='password']:focus,
|
||||
input[type='search']:focus,
|
||||
input[type='tel']:focus,
|
||||
input[type='text']:focus,
|
||||
input[type='url']:focus,
|
||||
input[type='color']:focus,
|
||||
input[type='date']:focus,
|
||||
input[type='month']:focus,
|
||||
input[type='week']:focus,
|
||||
input[type='datetime']:focus,
|
||||
input[type='datetime-local']:focus,
|
||||
input:not([type]):focus,
|
||||
textarea:focus,
|
||||
select:focus {
|
||||
border-color: var(--fwdekker-theme-color);
|
||||
h2 {
|
||||
--typography-spacing-vertical: 1.3125rem;
|
||||
}
|
||||
|
||||
a {
|
||||
color: var(--fwdekker-theme-color);
|
||||
h3 {
|
||||
--typography-spacing-vertical: 1.125rem;
|
||||
}
|
||||
|
||||
h4 {
|
||||
--typography-spacing-vertical: 0.937rem;
|
||||
}
|
||||
|
||||
h5 {
|
||||
--typography-spacing-vertical: 0.84375rem;
|
||||
}
|
||||
|
||||
h6 {
|
||||
--typography-spacing-vertical: 0.75rem;
|
||||
}
|
||||
|
||||
/* pico.css: Reduce `article` margins */
|
||||
article {
|
||||
margin: calc(var(--block-spacing-vertical) / 2) 0;
|
||||
|
||||
padding-top: calc(var(--block-spacing-vertical) / 2);
|
||||
padding-bottom: calc(var(--block-spacing-vertical) / 2);
|
||||
}
|
||||
|
||||
article > header,
|
||||
article > footer {
|
||||
padding-top: calc(var(--block-spacing-vertical) / 2);
|
||||
padding-bottom: calc(var(--block-spacing-vertical) / 2);
|
||||
}
|
||||
|
||||
article > header {
|
||||
margin-top: calc(var(--block-spacing-vertical) / -2);
|
||||
margin-bottom: calc(var(--block-spacing-vertical) / 2);
|
||||
}
|
||||
|
||||
article > footer {
|
||||
margin-top: calc(var(--block-spacing-vertical) / 2);
|
||||
margin-bottom: calc(var(--block-spacing-vertical) / -2);
|
||||
}
|
||||
|
||||
article > footer > form > article {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
article > header > hgroup,
|
||||
article > header > h1,
|
||||
article > header > h2,
|
||||
article > header > h3,
|
||||
article > header > h4,
|
||||
article > header > h5,
|
||||
article > header > h6,
|
||||
article > footer > hgroup,
|
||||
article > footer > h1,
|
||||
article > footer > h2,
|
||||
article > footer > h3,
|
||||
article > footer > h4,
|
||||
article > footer > h5,
|
||||
article > footer > h6 {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
|
|
@ -0,0 +1,7 @@
|
|||
import * as template from "./Template";
|
||||
import * as storage from "./Storage";
|
||||
import * as validation from "./Validation";
|
||||
|
||||
(window as any).fwdekker = template;
|
||||
(window as any).fwdekker.storage = storage;
|
||||
(window as any).fwdekker.validation = validation;
|
|
@ -0,0 +1,201 @@
|
|||
/**
|
||||
* Stores key-value pairs.
|
||||
*/
|
||||
export interface Storage {
|
||||
/**
|
||||
* Removes the data from storage.
|
||||
*/
|
||||
clear(): void;
|
||||
|
||||
/**
|
||||
* Retrieves an array from storage.
|
||||
*
|
||||
* @param name the name of the array to retrieve
|
||||
* @param def the value to return if no array is stored with the given name
|
||||
*/
|
||||
getArray(name: string, def: any[]): any[];
|
||||
|
||||
/**
|
||||
* Stores an array.
|
||||
*
|
||||
* @param name the name of the array to store
|
||||
* @param value the array to store under the given name
|
||||
*/
|
||||
setArray(name: string, value: any[]): void;
|
||||
|
||||
/**
|
||||
* Retrieves a boolean from storage.
|
||||
*
|
||||
* @param name the name of the boolean to retrieve
|
||||
* @param def the value to return if no boolean is stored with the given name
|
||||
*/
|
||||
getBoolean(name: string, def: boolean): boolean;
|
||||
|
||||
/**
|
||||
* Stores a boolean.
|
||||
*
|
||||
* @param name the name of the boolean to store
|
||||
* @param value the boolean to store under the given name
|
||||
*/
|
||||
setBoolean(name: string, value: boolean): void;
|
||||
|
||||
/**
|
||||
* Retrieves a number from storage.
|
||||
*
|
||||
* @param name the name of the number to retrieve
|
||||
* @param def the value to return if no number is stored with the given name
|
||||
*/
|
||||
getNumber(name: string, def: number): number;
|
||||
|
||||
/**
|
||||
* Stores a number.
|
||||
*
|
||||
* @param name the name of the number to store
|
||||
* @param value the number to store under the given name
|
||||
*/
|
||||
setNumber(name: string, value: number): void;
|
||||
|
||||
/**
|
||||
* Retrieves a string from storage.
|
||||
*
|
||||
* @param name the name of the string to retrieve
|
||||
* @param def the value to return if no string is stored with the given name
|
||||
*/
|
||||
getString(name: string, def: string): string;
|
||||
|
||||
/**
|
||||
* Stores a string.
|
||||
*
|
||||
* @param name the name of the string to store
|
||||
* @param value the number to store under the given name
|
||||
*/
|
||||
setString(name: string, value: string): void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Stores key-value pairs in a single entry in `localStorage`.
|
||||
*/
|
||||
export class LocalStorage implements Storage {
|
||||
private readonly key: string;
|
||||
private cache: { [key: string]: string } | null = null;
|
||||
|
||||
|
||||
/**
|
||||
* Constructs a new persistent storage item under the given key.
|
||||
*
|
||||
* @param key the unique identifier to store the data under
|
||||
*/
|
||||
constructor(key: string) {
|
||||
this.key = key;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Reads the object stored in local storage.
|
||||
*
|
||||
* @return the object stored in local storage, or an empty object if there is nothing in the local storage
|
||||
* @private
|
||||
*/
|
||||
private read(): { [key: string]: string } {
|
||||
if (this.cache === null)
|
||||
this.cache = JSON.parse(localStorage.getItem(this.key) ?? "{}");
|
||||
|
||||
return this.cache!;
|
||||
}
|
||||
|
||||
/**
|
||||
* Writes the given object to local storage.
|
||||
*
|
||||
* @param item the object to write to local storage
|
||||
* @private
|
||||
*/
|
||||
private write(item: { [key: string]: string }): void {
|
||||
this.cache = item;
|
||||
localStorage.setItem(this.key, JSON.stringify(item));
|
||||
}
|
||||
|
||||
|
||||
clear(): void {
|
||||
this.cache = null;
|
||||
localStorage.removeItem(this.key);
|
||||
}
|
||||
|
||||
getArray(name: string, def: any[] = []): any[] {
|
||||
const array = this.read()[name];
|
||||
return array === undefined ? def : JSON.parse(array);
|
||||
}
|
||||
|
||||
setArray(name: string, value: any[]): void {
|
||||
this.setString(name, JSON.stringify(value));
|
||||
}
|
||||
|
||||
getBoolean(name: string, def: boolean = false): boolean {
|
||||
return this.getString(name, def ? "true" : "false") === "true";
|
||||
}
|
||||
|
||||
setBoolean(name: string, value: boolean): void {
|
||||
this.setString(name, "" + value);
|
||||
}
|
||||
|
||||
getNumber(name: string, def: number = 0): number {
|
||||
return +this.getString(name, "" + def);
|
||||
}
|
||||
|
||||
setNumber(name: string, value: number): void {
|
||||
this.setString(name, "" + value);
|
||||
}
|
||||
|
||||
getString(name: string, def: string = ""): string {
|
||||
return this.read()[name] ?? def;
|
||||
}
|
||||
|
||||
setString(name: string, value: string): void {
|
||||
const item = this.read();
|
||||
item[name] = value;
|
||||
this.write(item);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Stores key-value pairs in an object.
|
||||
*/
|
||||
export class MemoryStorage implements Storage {
|
||||
private storage: { [key: string]: any } = {};
|
||||
|
||||
|
||||
clear(): void {
|
||||
this.storage = {};
|
||||
}
|
||||
|
||||
setArray(name: string, value: any[] = []): void {
|
||||
this.storage[name] = value;
|
||||
}
|
||||
|
||||
getArray(name: string, def: any[]): any[] {
|
||||
return this.storage[name] ?? def;
|
||||
}
|
||||
|
||||
setBoolean(name: string, value: boolean): void {
|
||||
this.storage[name] = value;
|
||||
}
|
||||
|
||||
getBoolean(name: string, def: boolean): boolean {
|
||||
return this.storage[name] ?? def;
|
||||
}
|
||||
|
||||
setNumber(name: string, value: number): void {
|
||||
this.storage[name] = value;
|
||||
}
|
||||
|
||||
getNumber(name: string, def: number): number {
|
||||
return this.storage[name] ?? def;
|
||||
}
|
||||
|
||||
setString(name: string, value: string): void {
|
||||
this.storage[name] = value;
|
||||
}
|
||||
|
||||
getString(name: string, def: string): string {
|
||||
return this.storage[name] ?? def;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,276 @@
|
|||
/**
|
||||
* Converts the given string to an HTML element.
|
||||
*
|
||||
* @param string the string to convert to an HTML element
|
||||
* @param query the type of element to return, or `undefined` to return the first element
|
||||
* @returns the HTML element described by the given string
|
||||
*/
|
||||
export function stringToHtml(string: string, query: string = "*"): HTMLElement {
|
||||
return (new DOMParser()).parseFromString(string, "text/html").body.querySelector(query)!;
|
||||
}
|
||||
|
||||
/**
|
||||
* Alias for `root.querySelector(query)`.
|
||||
*
|
||||
* @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
|
||||
*/
|
||||
export function $(query: string | null | undefined, root?: HTMLElement): HTMLElement | null {
|
||||
if (query == null) return null;
|
||||
return root === undefined ? document.querySelector(query) : root.querySelector(query);
|
||||
}
|
||||
|
||||
/**
|
||||
* Alias for `root.querySelectorAll(query)`.
|
||||
*
|
||||
* @param query the query string
|
||||
* @param root the element to start searching in, or `undefined` if searching should start in `document`
|
||||
* @returns the elements identified by `query` in `root`
|
||||
*/
|
||||
export function $a(query: string, root?: HTMLElement): NodeListOf<Element> {
|
||||
return root === undefined ? document.querySelectorAll(query) : root.querySelectorAll(query);
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs the given function once the page is loaded.
|
||||
*
|
||||
* This function can be used multiple times. It does not overwrite existing callbacks for the page load event. If the
|
||||
* page has already loaded when this function is invoked, `fun` is invoked immediately inside this function.
|
||||
*
|
||||
* @param fun the function to run
|
||||
*/
|
||||
export function doAfterLoad(fun: () => void): void {
|
||||
if (document.readyState === "complete") {
|
||||
fun();
|
||||
return;
|
||||
}
|
||||
|
||||
const oldOnLoad = onload || (() => {
|
||||
});
|
||||
|
||||
onload = (() => {
|
||||
// @ts-ignore Works fine
|
||||
oldOnLoad();
|
||||
fun();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the `content` attribute of the `<meta>` tag identified by `name`.
|
||||
*
|
||||
* @param name the name of the meta tag of which to return the `content`
|
||||
* @return the `content` attribute of the `<meta>` tag identified by `name`, or `null` if the meta tag has no content,
|
||||
* or `undefined` if the meta tag does not exist
|
||||
*/
|
||||
export function getMetaProperty(name: string): string | null | undefined {
|
||||
const metaTag = $(`meta[name="${name}"]`);
|
||||
return metaTag == null ? undefined : metaTag.getAttribute("content");
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Creates a navigation element for navigating through the website.
|
||||
*
|
||||
* Fetches entries asynchronously from the website's API.
|
||||
*
|
||||
* @param highlightPath the path to highlight together with its parents, or `undefined` if no element should be
|
||||
* highlighted
|
||||
* @param cb the callback to execute on the fetched entries, to prevent the need to re-fetch elsewhere, or `undefined`
|
||||
* if no callback should be invoked
|
||||
* @returns a base navigation element that will eventually be filled with contents
|
||||
*/
|
||||
function nav(highlightPath?: string, cb?: (json: any) => void): HTMLElement {
|
||||
const nav = stringToHtml(`<nav class="fwd-nav"></nav>`);
|
||||
|
||||
const checkbox = stringToHtml(`<input id="fwd-nav-hamburger-checkbox" type="checkbox" hidden />`);
|
||||
nav.appendChild(checkbox);
|
||||
|
||||
const base = stringToHtml(
|
||||
`<ul><li ${highlightPath === "/" ? `class="fwd-nav-highlighted" aria-current="page"` : ""}>` +
|
||||
`<a id="logo" href="https://fwdekker.com/">FWDekker</a>` +
|
||||
`</li></ul>`
|
||||
);
|
||||
fetch("https://fwdekker.com/api/nav/")
|
||||
.then(it => it.json())
|
||||
.then(json => {
|
||||
if (cb !== undefined)
|
||||
cb(json);
|
||||
|
||||
json.entries.forEach((it: any) => base.appendChild(unpackEntry(it, "/", highlightPath)));
|
||||
|
||||
document.body.addEventListener(
|
||||
"click",
|
||||
() => $a("li", base).forEach(it => it.classList.remove("fwd-nav-active")),
|
||||
{capture: true}
|
||||
);
|
||||
})
|
||||
.catch(error => console.error("Failed to fetch navigation elements", error));
|
||||
nav.appendChild(base);
|
||||
|
||||
const label = stringToHtml(`<label id="fwd-nav-hamburger-label" for="fwd-nav-hamburger-checkbox">☰</label>`);
|
||||
nav.appendChild(label);
|
||||
|
||||
return nav;
|
||||
}
|
||||
|
||||
/**
|
||||
* Unpacks a navigation entry returned from the navigation API into an HTML element.
|
||||
*
|
||||
* @param entry the entry to unpack
|
||||
* @param parentPath the current path traversed, found by joining the names of the entries with `/`s; always starts and
|
||||
* ends with a `/`
|
||||
* @param highlightPath the path to highlight together with its parents, or `undefined` if no path should be highlighted
|
||||
* @returns the navigation list entry
|
||||
*/
|
||||
function unpackEntry(entry: any, parentPath: string = "/", highlightPath?: string): HTMLLIElement {
|
||||
const path = `${parentPath + entry.name}/`;
|
||||
const hasChildren = entry.entries.length !== 0;
|
||||
|
||||
const li = document.createElement("li");
|
||||
if (highlightPath === path) li.setAttribute("aria-current", "page");
|
||||
if (highlightPath?.startsWith(path) ?? false) li.classList.add("fwd-nav-highlighted");
|
||||
if (entry.border) li.classList.add("fwd-nav-separator");
|
||||
|
||||
const a = document.createElement("a");
|
||||
a.innerText = entry.name;
|
||||
a.tabIndex = 0;
|
||||
if (hasChildren) {
|
||||
const depth = parentPath.split("/").length - 2; // -1 because count parts, then another -1 because of leading /
|
||||
a.innerText += ` ${depth === 0 ? "▾" : "▸"}`;
|
||||
}
|
||||
if (entry.link != null) {
|
||||
a.href = entry.link;
|
||||
|
||||
if (entry.link !== "#" && !/^https:\/\/.*fwdekker.com/i.test(entry.link))
|
||||
a.target = "_blank";
|
||||
}
|
||||
li.addEventListener("click", () => li.classList.add("fwd-nav-active"));
|
||||
li.appendChild(a);
|
||||
|
||||
if (hasChildren) {
|
||||
const ul = document.createElement("ul");
|
||||
entry.entries
|
||||
.map((it: any) => unpackEntry(it, path, highlightPath))
|
||||
.forEach((it: HTMLLIElement) => ul.appendChild(it));
|
||||
li.appendChild(ul);
|
||||
}
|
||||
|
||||
return li;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a footer element with the given data.
|
||||
*
|
||||
* Setting an argument to `undefined` or not giving that argument will cause the default value to be used. Setting an
|
||||
* argument to `null` will result in a footer without the corresponding element.
|
||||
*
|
||||
* @param author the author
|
||||
* @param authorURL the URL to link the author's name to
|
||||
* @param license the type of license
|
||||
* @param licenseURL the URL to the license file
|
||||
* @param vcs the type of version control
|
||||
* @param vcsURL the URL to the repository
|
||||
* @param version the page version
|
||||
* @param privacyPolicyURL the URL to the privacy policy
|
||||
* @returns a footer element
|
||||
*/
|
||||
function footer(
|
||||
{
|
||||
author = undefined,
|
||||
authorURL = undefined,
|
||||
license = undefined,
|
||||
licenseURL = undefined,
|
||||
vcs = undefined,
|
||||
vcsURL = undefined,
|
||||
version = undefined,
|
||||
privacyPolicyURL = undefined
|
||||
}: {
|
||||
author: string | null | undefined,
|
||||
authorURL: string | null | undefined,
|
||||
license: string | null | undefined,
|
||||
licenseURL: string | null | undefined,
|
||||
vcs: string | null | undefined,
|
||||
vcsURL: string | null | undefined,
|
||||
version: string | null | undefined,
|
||||
privacyPolicyURL: string | null | undefined
|
||||
}
|
||||
): HTMLElement {
|
||||
if (author === undefined) author = "Florine W. Dekker";
|
||||
if (authorURL === undefined) authorURL = "https://fwdekker.com/";
|
||||
if (license === undefined) license = "MIT";
|
||||
if (licenseURL === undefined && vcsURL !== undefined) licenseURL = `${vcsURL}src/branch/main/LICENSE`;
|
||||
if (vcs === undefined && vcsURL !== undefined) vcs = "git";
|
||||
if (privacyPolicyURL === undefined) privacyPolicyURL = "https://fwdekker.com/privacy/";
|
||||
|
||||
return stringToHtml(
|
||||
`<footer class="fwd-footer"><hr /><small>` +
|
||||
footerLink("Made by ", author, authorURL, ". ") +
|
||||
footerLink("Licensed ", license, licenseURL, ". ") +
|
||||
footerLink("Source and support on ", vcs, vcsURL, ". ") +
|
||||
footerLink("Read the ", privacyPolicyURL && "privacy policy", privacyPolicyURL, ". ") +
|
||||
`</small><div id="fwd-footer-version"><small>${version || ""}</small></div></footer>`
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Constructs a link that is used in footers.
|
||||
*
|
||||
* @param prefix the text to display before the text if the text is not `null` or `undefined`
|
||||
* @param text the text to display, or `null` or `undefined` if the returned element should be the empty string
|
||||
* @param url the URL to link the text to, or `null` or `undefined` if the text should not be a link
|
||||
* @param suffix the text to display after the text if the text is not `null` or `undefined`
|
||||
* @returns a footer link element
|
||||
*/
|
||||
function footerLink(prefix: string, text: string | null | undefined, url: string | null | undefined,
|
||||
suffix: string): string {
|
||||
if (text == null) return "";
|
||||
|
||||
return `${prefix}${url != null ? `<a href="${url}">${text}</a>` : text}${suffix}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs the functions `nav` and `footer` after the page has loaded using properties defined in the meta tags.
|
||||
*
|
||||
* Meta tags are read as interpreted as `<meta name="fwd:<function>:<property>" content="<value>" />` from the
|
||||
* document's head. Given a `function` of `nav` or `footer`, if the `value` of `fwd:<function>:target` is the ID of an
|
||||
* element in the page, that element is replaced by the output of `function`. The `function` is invoked with parameters
|
||||
* also read from meta elements, where each `property` is the same as the name of the parameter of that function, except
|
||||
* that instead of camelcase words are separated by dashes. For example, `vcsURL` becomes `vcs-url`.
|
||||
*
|
||||
* If a meta tag is missing, its value is considered `undefined`. If a meta tag exists but has no `content` attribute,
|
||||
* its value is considered `null`. Otherwise, the value is the string contents of the `content` attribute. If `null` is
|
||||
* not a valid value as a parameter for that function, its value is considered `undefined`.
|
||||
*/
|
||||
doAfterLoad(() => {
|
||||
const navTarget = $(getMetaProperty("fwd:nav:target"));
|
||||
if (navTarget != null) {
|
||||
navTarget.parentElement?.replaceChild(
|
||||
nav(getMetaProperty("fwd:nav:highlight-path") ?? undefined),
|
||||
navTarget
|
||||
);
|
||||
}
|
||||
|
||||
const footerTarget = $(getMetaProperty("fwd:footer:target"));
|
||||
if (footerTarget != null) {
|
||||
footerTarget.parentElement?.replaceChild(
|
||||
footer({
|
||||
author: getMetaProperty("fwd:footer:author"),
|
||||
authorURL: getMetaProperty("fwd:footer:author-url"),
|
||||
license: getMetaProperty("fwd:footer:license"),
|
||||
licenseURL: getMetaProperty("fwd:footer:license-url"),
|
||||
vcs: getMetaProperty("fwd:footer:vcs"),
|
||||
vcsURL: getMetaProperty("fwd:footer:vcs-url"),
|
||||
version: getMetaProperty("fwd:footer:version"),
|
||||
privacyPolicyURL: getMetaProperty("fwd:footer:privacy-policy-url"),
|
||||
}),
|
||||
footerTarget
|
||||
);
|
||||
}
|
||||
|
||||
if (getMetaProperty("fwd:auto:show-main") !== undefined)
|
||||
$("main")?.classList?.remove("hidden");
|
||||
|
||||
if (getMetaProperty("fwd:auto:autofocus") !== undefined)
|
||||
$("[autofocus]")?.focus();
|
||||
});
|
|
@ -0,0 +1,210 @@
|
|||
import {$, $a, doAfterLoad, getMetaProperty} from "./Template";
|
||||
|
||||
|
||||
/**
|
||||
* Removes all validation-related information from `form`.
|
||||
*
|
||||
* @param form the form to hide validation information from
|
||||
*/
|
||||
export function clearFormValidity(form: HTMLFormElement): void {
|
||||
clearMessageStatus(form);
|
||||
$a("input", form).forEach((input: Element) => {
|
||||
if (!(input instanceof HTMLInputElement)) return;
|
||||
|
||||
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
|
||||
*/
|
||||
export function showMessageType(card: HTMLElement | HTMLFormElement,
|
||||
message?: string,
|
||||
type?: "busy" | "error" | "info" | "success" | "warning"): void {
|
||||
if (card instanceof HTMLFormElement) {
|
||||
if (card.dataset.statusCard == null) return;
|
||||
|
||||
const formCard = $(`#${card.dataset.statusCard}`);
|
||||
if (formCard == null) return;
|
||||
|
||||
card = formCard;
|
||||
}
|
||||
const output = $("output", card)!;
|
||||
|
||||
output.removeAttribute("aria-busy");
|
||||
card.classList.remove("hidden", "error", "info", "success", "warning");
|
||||
|
||||
if (message == null || type == null) {
|
||||
card.classList.add("hidden");
|
||||
output.innerHTML = "";
|
||||
} else {
|
||||
if (type === "busy") output.setAttribute("aria-busy", "true");
|
||||
else card.classList.add(type);
|
||||
|
||||
output.innerHTML = message;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes the message in `card`, hiding it in the process.
|
||||
*
|
||||
* @param card the card to clear the message from
|
||||
*/
|
||||
export function clearMessageStatus(card: HTMLElement): void {
|
||||
showMessageType(card);
|
||||
}
|
||||
|
||||
/**
|
||||
* Shows a busy message in `card`, i.e. with a loading indicator.
|
||||
*
|
||||
* @param card the card to show `message` in
|
||||
* @param message the message to show in `card`
|
||||
*/
|
||||
export function showMessageBusy(card: HTMLElement, message: string): void {
|
||||
showMessageType(card, message, "busy");
|
||||
}
|
||||
|
||||
/**
|
||||
* Shows an error message in `card`.
|
||||
*
|
||||
* @param card the card to show `message` in
|
||||
* @param message the error message to show in `card`
|
||||
*/
|
||||
export 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`
|
||||
*/
|
||||
export 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`
|
||||
*/
|
||||
export 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`
|
||||
*/
|
||||
export function showMessageWarning(card: HTMLElement, message: string): void {
|
||||
showMessageType(card, message, "warning");
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Marks `input` as neither valid nor invalid.
|
||||
*
|
||||
* @param input
|
||||
*/
|
||||
export function clearInputValidity(input: HTMLInputElement): void {
|
||||
input.classList.remove("valid", "invalid");
|
||||
input.removeAttribute("aria-invalid");
|
||||
input.removeAttribute("aria-errormessage");
|
||||
|
||||
const label = $(`label[for="${input.id}"]`) ?? $(`*[data-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.innerHTML = 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
|
||||
* @param focus `true` if `input` should receive focus
|
||||
*/
|
||||
export function showInputInvalid(input: HTMLInputElement, message?: string, focus: boolean = true): void {
|
||||
clearInputValidity(input);
|
||||
|
||||
input.classList.add("invalid");
|
||||
input.setAttribute("aria-invalid", "true");
|
||||
if (focus) input.focus();
|
||||
|
||||
const label = $(`label[for="${input.id}"]`) ?? $(`*[data-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.innerHTML = 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
|
||||
*/
|
||||
export function showInputValid(input: HTMLInputElement, message?: string): void {
|
||||
clearInputValidity(input);
|
||||
|
||||
input.classList.add("valid");
|
||||
input.setAttribute("aria-invalid", "false");
|
||||
|
||||
const label = $(`label[for="${input.id}"]`) ?? $(`*[data-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.innerHTML = 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: Element) => {
|
||||
if (!(close instanceof HTMLElement)) return;
|
||||
|
||||
close.addEventListener("click", (event: MouseEvent) => {
|
||||
event.preventDefault();
|
||||
|
||||
close.parentElement!.classList.add("hidden");
|
||||
});
|
||||
});
|
||||
|
||||
$a("small[data-hint]").forEach((hint: Element) => {
|
||||
if (!(hint instanceof HTMLElement)) return;
|
||||
|
||||
hint.innerHTML = hint.dataset["hint"] ?? "";
|
||||
});
|
||||
});
|
|
@ -1,213 +0,0 @@
|
|||
/**
|
||||
* Converts the given string to an HTML element.
|
||||
*
|
||||
* @param string the string to convert to an HTML element
|
||||
* @param query the type of element to return
|
||||
* @returns {HTMLElement} the HTML element described by the given string
|
||||
*/
|
||||
const stringToHtml = function(string, query) {
|
||||
return (new DOMParser()).parseFromString(string, "text/html").body.querySelector(query);
|
||||
}
|
||||
|
||||
/**
|
||||
* Alias for `document.querySelector`.
|
||||
*
|
||||
* @param q {string} the query string
|
||||
* @returns {HTMLElement} the element identified by the query string
|
||||
*/
|
||||
const $ = q => document.querySelector(q);
|
||||
|
||||
/**
|
||||
* Runs the given function once the page is loaded.
|
||||
*
|
||||
* This function can be used multiple times. It does not overwrite existing callbacks for the page load event. If the
|
||||
* page has already loaded when this function is invoked, `fun` is invoked immediately inside this function.
|
||||
*
|
||||
* @param fun {function(...*): *} the function to run
|
||||
*/
|
||||
const doAfterLoad = function(fun) {
|
||||
if (document.readyState === "complete") {
|
||||
fun();
|
||||
return;
|
||||
}
|
||||
|
||||
const oldOnLoad = onload || (() => {
|
||||
});
|
||||
|
||||
onload = (() => {
|
||||
oldOnLoad();
|
||||
fun();
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
/**
|
||||
* Creates a navigation element for navigating through the website.
|
||||
*
|
||||
* Fetches entries asynchronously from the website's API.
|
||||
*
|
||||
* @param [highlightPath] {String} the path to highlight together with its parents
|
||||
* @returns {HTMLElement} a base navigation element that will eventually be filled with contents
|
||||
*/
|
||||
const nav = function(highlightPath = "") {
|
||||
const base = stringToHtml(
|
||||
`<ul><li><a href="https://fwdekker.com/">` +
|
||||
`<div class="logo"><img class="logo" src="https://fwdekker.com/favicon.png" alt="FWDekker" /></div>` +
|
||||
`<b>FWDekker</b>` +
|
||||
`</a></li></ul>`,
|
||||
"ul"
|
||||
);
|
||||
|
||||
fetch("https://fwdekker.com/api/nav/")
|
||||
.then(it => it.json())
|
||||
.then(json => {
|
||||
json.entries.forEach(entry => base.appendChild(stringToHtml(unpackEntry(entry, "/", highlightPath), "li")))
|
||||
})
|
||||
.catch(e => {
|
||||
console.error("Failed to fetch navigation elements", e);
|
||||
return [];
|
||||
});
|
||||
|
||||
const nav = stringToHtml(
|
||||
`<nav>` +
|
||||
`<input id="nav-hamburger-checkbox" type="checkbox" hidden />` +
|
||||
`<label id="nav-hamburger-label" for="nav-hamburger-checkbox">☰</label>` +
|
||||
`</nav>`,
|
||||
"nav"
|
||||
);
|
||||
nav.appendChild(base);
|
||||
return nav;
|
||||
};
|
||||
|
||||
/**
|
||||
* Unpacks a navigation entry returned from the navigation API into an HTML element.
|
||||
*
|
||||
* @param entry {Object} the entry to unpack
|
||||
* @param [path] {number} the current path traversed, found by joining the names of the entries with `/`s; always starts
|
||||
* and ends with a `/`
|
||||
* @param [highlightPath] {String} the path to highlight together with its parents
|
||||
* @returns {string} the navigation list entry as HTML, described by its children
|
||||
*/
|
||||
const unpackEntry = function(entry, path = "/", highlightPath = "") {
|
||||
const shouldHighlight = highlightPath.startsWith(`${path + entry.name}/`);
|
||||
const isExternalLink = !(/^https:\/\/.*fwdekker.com/.test(entry.link)) && entry.link !== "#";
|
||||
const formattedName = (isExternalLink ? "⎋ " : "") + entry.name;
|
||||
|
||||
if (entry.entries.length === 0)
|
||||
return `<li class="${shouldHighlight ? "currentPage" : ""}"><a href="${entry.link}">${formattedName}</a></li>`;
|
||||
|
||||
const depth = path.split("/").length - 2; // -1 because count parts, then another -1 because of leading `/`
|
||||
const arrow = depth === 0 ? "▾" : "▸";
|
||||
|
||||
return "" +
|
||||
`<li class="${shouldHighlight ? "currentPage" : ""}">` +
|
||||
`<a href="${entry.link}">${formattedName} ${arrow}</a>` +
|
||||
`<ul>${entry.entries.map(it => unpackEntry(it, `${path + entry.name}/`, highlightPath)).join("")}</ul>` +
|
||||
`</li>`;
|
||||
};
|
||||
|
||||
|
||||
/**
|
||||
* Creates a header element with the given title and description.
|
||||
*
|
||||
* @param [title] {string} the title to display, possibly including HTML
|
||||
* @param [description] {string} the description to display, possibly including HTML
|
||||
* @returns {HTMLElement} a header element
|
||||
*/
|
||||
const header = function({title, description}) {
|
||||
if (title === undefined && description === undefined)
|
||||
return stringToHtml(`<header></header>`, "header");
|
||||
|
||||
return stringToHtml(
|
||||
`<header><section class="container">` +
|
||||
(title !== undefined ? `<h1>${title}</h1>` : "") +
|
||||
(description !== undefined ? `<p><em>${description}</em></p>` : "") +
|
||||
`</section></header>`,
|
||||
"header"
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
/**
|
||||
* Creates a footer element with the given data.
|
||||
*
|
||||
* Setting an argument to `undefined` or not giving that argument will cause the default value to be used. Setting an
|
||||
* argument to `null` will result in a footer without the corresponding element.
|
||||
*
|
||||
* @param [author] {string|null|undefined} the author
|
||||
* @param [authorURL] {string|null|undefined} the URL to link the author's name to
|
||||
* @param [license] {string|null|undefined} the type of license
|
||||
* @param [licenseURL] {string|null|undefined} the URL to the license file
|
||||
* @param [vcs] {string|null|undefined} the type of version control
|
||||
* @param [vcsURL] {string|null|undefined} the URL to the repository
|
||||
* @param [version] {string|null|undefined} the page version
|
||||
* @param [privacyPolicyURL] {string|null|undefined} the URL to the privacy policy
|
||||
* @returns {HTMLElement} a footer element
|
||||
*/
|
||||
const footer = function(
|
||||
{
|
||||
author = undefined,
|
||||
authorURL = undefined,
|
||||
license = undefined,
|
||||
licenseURL = undefined,
|
||||
vcs = undefined,
|
||||
vcsURL = undefined,
|
||||
version = undefined,
|
||||
privacyPolicyURL = undefined
|
||||
}) {
|
||||
if (author === undefined) author = "F.W. Dekker";
|
||||
if (authorURL === undefined) authorURL = "https://fwdekker.com/";
|
||||
if (license === undefined) license = "MIT License";
|
||||
if (licenseURL === undefined && vcsURL !== undefined) licenseURL = `${vcsURL}src/branch/master/LICENSE`;
|
||||
if (vcs === undefined && vcsURL !== undefined) vcs = "git";
|
||||
if (privacyPolicyURL === undefined) privacyPolicyURL = "https://fwdekker.com/privacy/";
|
||||
|
||||
return stringToHtml(
|
||||
`<footer><section class="container">` +
|
||||
footerLink("Made by ", author, authorURL, ". ") +
|
||||
footerLink("Licensed under the ", license, licenseURL, ". ") +
|
||||
footerLink("Source code available on ", vcs, vcsURL, ". ") +
|
||||
footerLink("Consider reading the ", privacyPolicyURL && "privacy policy", privacyPolicyURL, ". ") +
|
||||
`<div id="footerVersion">${version || ""}</div>` +
|
||||
`</section></footer>`,
|
||||
"footer");
|
||||
};
|
||||
|
||||
/**
|
||||
* Constructs a link that is used in footers.
|
||||
*
|
||||
* @param prefix {string} the text to display before the text if the text is not undefined
|
||||
* @param text {string|null} the text to display, or `null` if the returned element should be empty
|
||||
* @param url {string|null} the URL to link the text to, or `null` if the text should not be a link
|
||||
* @param suffix {string} the text to display after the text if the text is not undefined
|
||||
* @returns {string} a footer link element
|
||||
*/
|
||||
const footerLink = function(prefix, text, url, suffix) {
|
||||
if (text === null) return "";
|
||||
|
||||
return `${prefix}${url !== null ? `<a href="${url}">${text}</a>` : text}${suffix}`;
|
||||
};
|
||||
|
||||
|
||||
/**
|
||||
* Unhides the main element on the page and applies default display styling.
|
||||
*/
|
||||
const showPage = function() {
|
||||
// Flex-based footer positioning, taken from https://stackoverflow.com/a/12253099
|
||||
const main = $("main");
|
||||
// TODO: Remove .style commands once all pages are migrated
|
||||
main.style.display = "flex";
|
||||
main.style.flexDirection = "column";
|
||||
main.style.minHeight = "100%";
|
||||
main.classList.remove("hidden");
|
||||
main.classList.add("pageVisible");
|
||||
|
||||
// TODO: Remove .style commands once all pages are migrated
|
||||
const contents = $("#contents");
|
||||
contents.style.flex = "1";
|
||||
contents.classList.add("pageVisible");
|
||||
}
|
||||
|
||||
|
||||
// Export to namespace
|
||||
fwdekker = {stringToHtml, $, doAfterLoad, nav, header, footer, showPage};
|
|
@ -3,53 +3,113 @@
|
|||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta name="author" content="F.W. Dekker" />
|
||||
<meta name="author" content="Florine W. Dekker" />
|
||||
<meta name="application-name" content="Test" />
|
||||
<meta name="description" content="A test page" />
|
||||
<meta name="theme-color" content="#0033cc" />
|
||||
|
||||
<title>Tools | FWDekker</title>
|
||||
<meta name="fwd:nav:target" content="#nav" />
|
||||
<meta name="fwd:nav:highlight-path" content="/Tools/Dice/" />
|
||||
<meta name="fwd:footer:target" content="#footer" />
|
||||
<meta name="fwd:footer:vcs-url" content="https://git.fwdekker.com/fwdekker.com/template/" />
|
||||
<meta name="fwd:footer:version" content="vTEST" />
|
||||
<meta name="fwd:validation:load-forms" />
|
||||
|
||||
<title>Template test | FWDekker</title>
|
||||
|
||||
<link rel="stylesheet" href="https://static.fwdekker.com/fonts/roboto/roboto.css" />
|
||||
<!--suppress HtmlUnknownTarget -->
|
||||
<link rel="stylesheet" href="../../dist/template.css" />
|
||||
</head>
|
||||
<body>
|
||||
<noscript>
|
||||
<noscript class="fwd-js-notice">
|
||||
<p>
|
||||
This website does not function if JavaScript is disabled.
|
||||
Please check the <a href="https://www.enable-javascript.com/">
|
||||
instructions on how to enable JavaScript in your web browser</a>.
|
||||
</p>
|
||||
</noscript>
|
||||
<main class="hidden">
|
||||
<div id="nav"></div>
|
||||
<div id="contents">
|
||||
<div id="header"></div>
|
||||
<nav id="nav"></nav>
|
||||
<main class="container grid-with-sidebar">
|
||||
<aside>
|
||||
Table of contents test
|
||||
</aside>
|
||||
<div role="document">
|
||||
<section>
|
||||
<header class="fwd-header">
|
||||
<hgroup>
|
||||
<h1><a href=".">Test</a></h1>
|
||||
<h2>A test page</h2>
|
||||
</hgroup>
|
||||
</header>
|
||||
|
||||
<section class="container">
|
||||
<p>These are the page contents.</p>
|
||||
<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>
|
||||
<p>These are the page contents.</p>
|
||||
<p><code>This is a code block</code></p>
|
||||
|
||||
|
||||
<article>
|
||||
<header>
|
||||
<hgroup>
|
||||
<h2>Log in</h2>
|
||||
<h3>Already have an account? Welcome back!</h3>
|
||||
</hgroup>
|
||||
</header>
|
||||
<form id="test-form" data-status-card="test-status-card" novalidate>
|
||||
<article id="test-status-card" class="status-card hidden">
|
||||
<output>Congrats!</output>
|
||||
<a class="close" href="#" aria-label="Close"></a>
|
||||
</article>
|
||||
|
||||
<label for="test-email">Email</label>
|
||||
<input id="test-email" type="email" name="email" autocomplete="on" />
|
||||
<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 id="test-password-hint" data-hint-for="test-password"></small>
|
||||
|
||||
<button id="login-button">Log in</button>
|
||||
</form>
|
||||
</article>
|
||||
</section>
|
||||
<footer id="footer"></footer>
|
||||
</div>
|
||||
<div id="footer"></div>
|
||||
</main>
|
||||
|
||||
<!--suppress HtmlUnknownTarget -->
|
||||
<script src="../../dist/template.js"></script>
|
||||
<script>
|
||||
const {$, doAfterLoad, footer, header, nav, showPage} = window.fwdekker;
|
||||
const {$} = window.fwdekker;
|
||||
|
||||
doAfterLoad(() => {
|
||||
$("#nav").appendChild(nav("/Tools/Dice/"));
|
||||
$("#header").appendChild(header({title: "Test", description: "A test page"}));
|
||||
$("#footer").appendChild(footer({
|
||||
vcsURL: "https://git.fwdekker.com/FWDekker/tools/",
|
||||
version: "vTEST"
|
||||
}));
|
||||
showPage();
|
||||
const storage = new window.fwdekker.storage.MemoryStorage();
|
||||
storage.setNumber("test-key", 11);
|
||||
console.log("Expected: 11. Actual: " + storage.getNumber("test-key", 0) + ".");
|
||||
|
||||
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.showMessageBusy($("#global-status-card"), ":D");
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
@ -0,0 +1,11 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"target": "es2019",
|
||||
"strict": true,
|
||||
"rootDir": "./src/main/js/",
|
||||
"outDir": "./dist/js/"
|
||||
},
|
||||
"include": [
|
||||
"src/main/js/**/*.ts"
|
||||
]
|
||||
}
|
Loading…
Reference in New Issue