Compare commits

...

45 Commits

Author SHA1 Message Date
Florine W. Dekker 164a76d9fd
Change default license branch to main 2024-04-24 11:15:06 +02:00
Florine W. Dekker a6faa1ebe8
Bump dependencies 2023-12-01 13:01:41 +01:00
Florine W. Dekker a1d3384e32
Update dependencies 2023-03-02 14:41:21 +01:00
Florine W. Dekker eb12f0473a
Correctly load dark theme if dark is preferred 2022-12-17 19:20:12 +01:00
Florine W. Dekker 09cacd241f
Increase inline button padding 2022-12-16 22:39:16 +01:00
Florine W. Dekker c495714867
Let form declare status card 2022-12-16 21:39:05 +01:00
Florine W. Dekker 080c1f852c
Move some form styling around from other projects 2022-12-14 21:26:05 +01:00
Florine W. Dekker 7ecd9ec165
Load all hints and add inline button 2022-12-14 21:17:41 +01:00
Florine W. Dekker e5e6a0d347
Bold table headers 2022-12-14 20:45:27 +01:00
Florine W. Dekker cba23b4911
Remove top margin from form validation in footers 2022-12-14 20:39:37 +01:00
Florine W. Dekker 46da788e86
Fix sticky sidebar query 2022-12-12 17:58:55 +01:00
Florine W. Dekker 0ec60b3480
Add sticky sidebar class 2022-12-12 17:53:54 +01:00
Florine W. Dekker 7648e49999
Fix status card close button location and margin 2022-12-08 19:19:25 +01:00
Florine W. Dekker 201efeca8c
Implement storage/retrieval of strings 2022-11-26 15:36:09 +01:00
Florine W. Dekker c8c99373a3
Enforce custom colours when no theme is specified 2022-11-26 14:38:13 +01:00
Florine W. Dekker 9220ac6224
Add support for dark theme
Fixes #33.
2022-11-26 14:34:01 +01:00
Florine W. Dekker 6c8215bc0c
Do not override theme if already set 2022-11-26 14:07:33 +01:00
Florine W. Dekker 0be8fc9b97
Restore tab index for linkless nav elements 2022-11-24 18:17:47 +01:00
Florine W. Dekker c71fab3342
Give normal weight to checkbox and radio labels 2022-11-24 18:14:00 +01:00
Florine W. Dekker a208f13f4e
Highlight logo if on main page 2022-11-24 17:49:22 +01:00
Florine W. Dekker 2e33b8d762
Use mouse-enabled navbar for mobile
Works towards fixing #35.
2022-11-24 17:38:08 +01:00
Florine W. Dekker eb83cfa97c
Support linkess nav elements 2022-11-24 16:22:29 +01:00
Florine W. Dekker dc50ad5859
Rename container sidebar class
Fixes #29.
2022-11-23 12:13:38 +01:00
Florine W. Dekker 570e9c5764
Add navbar shadows
Fixes #32.
2022-11-23 11:59:50 +01:00
Florine W. Dekker 4d0f1dc5b4
Add border above some nav elements
Borders were added in api-nav v0.1.0.

Also simplify some CSS selectors, since they contained a lot of redundancy which made them harder to read.
2022-11-23 11:09:37 +01:00
Florine W. Dekker df1198b361
Improve colour contrasts
See also picocss/pico#276 on GitHub.
2022-11-23 01:10:43 +01:00
Florine W. Dekker 4aa27c567a
Use bold labels
Fixes #28.
2022-11-23 01:00:40 +01:00
Florine W. Dekker 2d46bf1969
Rewrite nav bar
Fixes #31. Also resolves some small issues with incorrect margins and such.
2022-11-23 00:58:13 +01:00
Florine W. Dekker 537d8db0e2
Implement `busy` message type for forms 2022-11-22 20:18:05 +01:00
Florine W. Dekker b97afae983
Implement auto-show and auto-focus
Fixes #30
2022-11-21 23:23:29 +01:00
Florine W. Dekker ae202b072b
Allow form validation without focus 2022-11-21 23:20:33 +01:00
Florine W. Dekker 64eb21dfb8
Implement right-side aside 2022-11-21 18:03:34 +01:00
Florine W. Dekker 24443ac455
Fix external link icon on Chromium
Fixes #27.
2022-11-21 15:57:56 +01:00
Florine W. Dekker 107962a83d
Support custom labels for validation 2022-11-21 08:25:45 +01:00
Florine W. Dekker 527b52069b
Remove redundant colours
Fixes #25.
2022-11-20 22:56:59 +01:00
Florine W. Dekker febc5d4189
Allow cardless form 2022-11-20 22:23:43 +01:00
Florine W. Dekker 6f15754c50
Allow HTML validation messages 2022-11-20 22:03:00 +01:00
Florine W. Dekker 9beda27c23
Bundle modules together into one
Fixes #26.
2022-11-20 21:18:46 +01:00
Florine W. Dekker 70465ef7bd
Implement validation module 2022-11-20 20:35:50 +01:00
Florine W. Dekker 858e577422
Ensure correct margins in cards 2022-11-20 16:28:39 +01:00
Florine W. Dekker bf4aa0289b
Rewrite `template.js` to TypeScript 2022-11-20 16:21:38 +01:00
Florine W. Dekker 723974d103
Remove iwb, remove templates, reduce margins 2022-11-19 22:12:32 +01:00
Florine W. Dekker 239be7235f
Ensure proper toc wrapping 2022-11-19 19:13:09 +01:00
Florine W. Dekker a35ca5efc6
Reduce heading margin 2022-11-19 18:13:27 +01:00
Florine W. Dekker 60c84bd48e
Slightly improve code style
To be honest, this is just to trigger a release.
2022-11-19 17:35:12 +01:00
16 changed files with 995 additions and 493 deletions

View File

@ -14,13 +14,23 @@ module.exports = grunt => {
},
},
focus: {
deploy: {
include: ["css", "storage", "template"],
dev: {
include: ["css", "ts"],
},
},
watch: {
css: {
files: ["src/main/**/*.css"],
tasks: ["cssmin"],
},
ts: {
files: ["src/main/**/*.ts"],
tasks: ["webpack:dev"],
},
},
webpack: {
storage: {
entry: "./src/main/js/Storage.ts",
options: {
entry: "./src/main/js/Main.ts",
module: {
rules: [
{
@ -34,46 +44,17 @@ module.exports = grunt => {
extensions: [".ts"],
},
output: {
filename: "storage.js",
filename: "template.js",
path: path.resolve(__dirname, "dist/"),
},
},
dev: {
mode: "development",
devtool: "inline-source-map",
},
deploy: {
mode: "production",
},
template: {
entry: "./src/main/js/template.js",
module: {
rules: [
{
test: /\.js$/i,
exclude: /node_modules/,
},
],
},
resolve: {
extensions: [".ts"],
},
output: {
library: "fwdekker-template",
libraryTarget: "umd",
filename: "template.js",
path: path.resolve(__dirname, "dist"),
},
mode: "production",
},
},
watch: {
css: {
files: ["src/main/**/*.css"],
tasks: ["cssmin"],
},
storage: {
files: ["src/main/**/*.ts"],
tasks: ["webpack:storage"],
},
template: {
files: ["src/main/**/*.js"],
tasks: ["webpack:template"],
},
},
});
@ -83,8 +64,9 @@ module.exports = grunt => {
grunt.loadNpmTasks("grunt-focus");
grunt.loadNpmTasks("grunt-webpack");
grunt.registerTask("deploy", ["webpack:storage", "webpack:template", "cssmin"]);
grunt.registerTask("deploy:server", ["deploy", "focus:deploy"]);
grunt.registerTask("dev", ["clean", "webpack:dev", "cssmin"]);
grunt.registerTask("dev:server", ["dev", "focus:dev"]);
grunt.registerTask("deploy", ["clean", "webpack:deploy", "cssmin"]);
grunt.registerTask("default", ["deploy"]);
grunt.registerTask("default", ["dev"]);
};

View File

@ -1,13 +1,20 @@
# FWDekker Template
The base template for pages on fwdekker.com.
This module contains templating functions (e.g. `nav`, `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.
The main functionality is provided in `template.js` and `template.css`.
There also exist optional modules for easily reusing common code.
Modules should be loaded after `template.js` and `template.css`.
Currently, the only module is `storage.js` for interfacing with local storage.
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
@ -22,8 +29,10 @@ $> npm ci
### Building
```shell script
# Build the template in `dist/` for deployment
# Build the tool in `dist/` for development
$> npm run dev
# Same as above, but automatically rerun it whenever files are changed
$> npm run dev:server
# Build the tool in `dist/` for deployment
$> npm run deploy
# Run the `deploy` task and automatically rerun it whenever files are changed
$> npm run deploy:server
```

BIN
package-lock.json generated

Binary file not shown.

View File

@ -1,6 +1,6 @@
{
"name": "@fwdekker/template",
"version": "3.0.0-RC1",
"version": "3.6.5",
"description": "The base template for pages on fwdekker.com.",
"author": "Florine W. Dekker",
"license": "MIT",
@ -19,24 +19,25 @@
],
"scripts": {
"clean": "grunt clean",
"deploy": "grunt deploy",
"deploy:server": "grunt deploy:server"
"dev": "grunt dev",
"dev:server": "grunt dev:server",
"deploy": "grunt deploy"
},
"dependencies": {
"@picocss/pico": "^1.5.6"
"@picocss/pico": "^1.5.10"
},
"devDependencies": {
"grunt": "^1.5.3",
"grunt": "^1.6.1",
"grunt-cli": "^1.4.3",
"grunt-contrib-clean": "^2.0.1",
"grunt-contrib-cssmin": "^4.0.0",
"grunt-contrib-cssmin": "^5.0.0",
"grunt-contrib-watch": "^1.1.0",
"grunt-focus": "^1.0.0",
"grunt-webpack": "^5.0.0",
"ts-loader": "^9.4.1",
"grunt-webpack": "^6.0.0",
"ts-loader": "^9.5.1",
"ts-node": "^10.9.1",
"typescript": "^4.9.3",
"webpack": "^5.75.0",
"webpack-cli": "^5.0.0"
"typescript": "^5.3.2",
"webpack": "^5.89.0",
"webpack-cli": "^5.1.4"
}
}

View File

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

View File

@ -1,29 +1,39 @@
/* Custom main colors */
:root {
--fwdekker-theme-color-triplet: 0, 51, 204;
--fwdekker-theme-color: rgb(var(--fwdekker-theme-color-triplet));
/* pico.css overrides, based on https://picocss.com/docs/customization.html */
--fwdekker-theme-color-dark-triplet: 0, 41, 163;
--fwdekker-theme-color-dark: rgb(var(--fwdekker-theme-color-dark-triplet));
--fwdekker-theme-color-very-dark-triplet: 0, 31, 122;
--fwdekker-theme-color-very-dark: rgb(var(--fwdekker-theme-color-very-dark-triplet));
--fwdekker-theme-color-light-triplet: 0, 61, 245;
--fwdekker-theme-color-light: rgb(var(--fwdekker-theme-color-light-triplet));
--fwdekker-theme-color-very-light-triplet: 31, 87, 255;
--fwdekker-theme-color-very-light: rgb(var(--fwdekker-theme-color-very-light-triplet));
/* 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;
}
}
/* pico.css overrides, based on https://picocss.com/docs/customization.html */
:root {
--primary: var(--fwdekker-theme-color) !important;
--primary-hover: var(--fwdekker-theme-color-light) !important;
--primary-focus: rgba(var(--fwdekker-theme-color-dark-triplet), 0.125) !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;

View File

@ -7,7 +7,10 @@ a[target="_blank"]::after {
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");
--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;
@ -35,31 +38,30 @@ a[target="_blank"]::after {
}
}
/* Input with button next to it */
.input-with-button {
display: flex;
}
/* Container with sidebar, e.g. for table of contents */
@media (min-width: 992px) {
:root {
--aside-width: 200px;
}
.input-with-button > *:first-child {
border-top-right-radius: 0;
border-bottom-right-radius: 0;
}
.input-with-button > *:not(:first-child) {
border-top-left-radius: 0;
border-bottom-left-radius: 0;
}
/* Container with table of contents */
@media (min-width: 576px) {
.container-with-toc {
.grid-with-sidebar {
display: grid;
grid-template-columns: 200px auto;
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: 576px) {
.container-with-toc aside {
@media (max-width: 992px) {
.grid-with-sidebar aside {
margin-bottom: var(--block-spacing-vertical);
}
}
@ -77,8 +79,8 @@ noscript.fwd-js-notice p {
/* Header */
header a[href="."] {
color: black;
header.fwd-header a[href="."] {
color: unset;
}

View File

@ -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;
}

View File

@ -1,140 +1,138 @@
/* Base elements */
nav.fwd-nav {
display: block;
z-index: 10;
margin: 0;
width: 100%;
background-color: var(--fwdekker-theme-color);
border-bottom: 1px solid #cccccc;
--padding: calc(2em / 3);
--fwd-nav-box-shadow: 0px 0px 10px 0px rgba(0, 0, 0, 0.75);
box-shadow: var(--fwd-nav-box-shadow);
}
nav.fwd-nav * {
vertical-align: middle;
nav.fwd-nav > ul {
display: flex;
align-items: start;
flex-wrap: wrap;
}
nav.fwd-nav a,
nav.fwd-nav a:link,
nav.fwd-nav a:visited,
nav.fwd-nav a:hover,
nav.fwd-nav a:active {
/* Ensures whole li is clickable */
width: 100%;
nav.fwd-nav > ul > li {
flex-grow: 0;
flex-basis: 0;
}
nav.fwd-nav a,
nav.fwd-nav a:link,
nav.fwd-nav a:visited,
nav.fwd-nav a:hover,
nav.fwd-nav a:active,
nav.fwd-nav #nav-hamburger-label {
display: inline-block;
margin: 0;
padding: calc(var(--padding)) calc(var(--padding));
height: 100%;
color: white;
}
nav.fwd-nav #nav-hamburger-label {
float: right;
}
nav.fwd-nav a[target="_blank"]::after {
margin-bottom: 0.2rem;
background-color: white;
}
/* Logo */
nav.fwd-nav .logo {
width: calc(1em + var(--padding));
height: calc(1em + var(--padding));
vertical-align: middle;
filter: brightness(0) invert(1);
}
nav.fwd-nav div.logo {
display: inline-block;
margin-right: calc(1em / 3);
}
/* First level nesting */
nav.fwd-nav ul {
display: block;
margin: 0;
padding: 0;
list-style: none;
}
nav.fwd-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.fwd-nav ul li:hover,
nav.fwd-nav ul li:focus-within,
nav.fwd-nav #nav-hamburger-label:hover,
nav.fwd-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.fwd-nav li.currentPage {
background-color: var(--fwdekker-theme-color-dark);
}
/* Second level nesting */
nav.fwd-nav ul li ul {
z-index: 11;
nav.fwd-nav ul ul {
display: none;
position: absolute;
top: 100%;
left: 0;
margin: 0;
box-shadow: var(--fwd-nav-box-shadow);
}
nav.fwd-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.fwd-nav ul li:hover > ul,
nav.fwd-nav ul li:focus-within > ul,
nav.fwd-nav ul li ul:hover {
display: block;
/* z-index */
nav.fwd-nav a {
position: relative;
}
nav.fwd-nav ul li ul li {
min-width: 7em;
width: 100%;
white-space: nowrap;
nav.fwd-nav > ul > li > ul {
z-index: 10;
}
/* Colors and optional styling */
nav.fwd-nav,
nav.fwd-nav ul {
background-color: var(--primary);
}
/* Hide hamburger-related elements */
nav.fwd-nav input[type="checkbox"] {
display: none;
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 #nav-hamburger-label {
nav.fwd-nav #fwd-nav-hamburger-label {
display: none;
}
}
@media (max-width: 576px) {
nav.fwd-nav input[type="checkbox"]:not(:checked) ~ ul li:not(:first-child) {
nav.fwd-nav #fwd-nav-hamburger-checkbox:not(:checked) ~ ul li:not(:first-child) {
display: none;
}
}

View File

@ -1,6 +1,95 @@
/* Make arrow next to dropdown visible */
select {
-webkit-appearance: menulist;
-moz-appearance: menulist;
appearance: auto;
/* pico.css: Improved text contrast (see also picocss/pico#276) */
:root {
--code-color: var(--color) !important;
}
:root[data-theme="light"] {
--muted-color: hsl(205deg, 15%, 41%) !important;
}
:root[data-theme="dark"] {
--muted-color: hsl(205deg, 12%, 59%) !important;
}
/* pico.css: Bold <label> and <th>, except for checkbox/radio labels */
:root {
--form-label-font-weight: bold;
}
tr th {
font-weight: bold;
}
input:where([type="checkbox"], [type="radio"]) + label {
font-weight: normal;
}
/* pico.css: Halve header margins */
h1 {
--typography-spacing-vertical: 1.5rem;
}
h2 {
--typography-spacing-vertical: 1.3125rem;
}
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;
}

7
src/main/js/Main.ts Normal file
View File

@ -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;

View File

@ -5,7 +5,7 @@ export interface Storage {
/**
* Removes the data from storage.
*/
clear(): void
clear(): void;
/**
* Retrieves an array from storage.
@ -13,52 +13,63 @@ export interface 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[]
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
* @protected
*/
setArray(name: string, value: any[]): void
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
* @protected
*/
getBoolean(name: string, def: boolean): boolean
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
* @protected
*/
setBoolean(name: string, value: boolean): void
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
* @protected
*/
getNumber(name: string, def: number): number
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
* @protected
*/
setNumber(name: string, value: number): void
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;
}
/**
@ -115,28 +126,32 @@ export class LocalStorage implements Storage {
}
setArray(name: string, value: any[]): void {
const item = this.read();
item[name] = JSON.stringify(value);
this.write(item);
this.setString(name, JSON.stringify(value));
}
getBoolean(name: string, def: boolean = false): boolean {
return (this.read()[name] ?? `${def}`) === "true";
return this.getString(name, def ? "true" : "false") === "true";
}
setBoolean(name: string, value: boolean): void {
const item = this.read();
item[name] = "" + value;
this.write(item);
this.setString(name, "" + value);
}
getNumber(name: string, def: number = 0): number {
return +(this.read()[name] ?? def);
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;
item[name] = value;
this.write(item);
}
}
@ -175,12 +190,12 @@ export class MemoryStorage implements Storage {
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;
}
}
// @ts-ignore
if (typeof window.fwdekker === "undefined")
// @ts-ignore
window.fwdekker = {};
// @ts-ignore
window.fwdekker.storage = {Storage, LocalStorage, MemoryStorage};

276
src/main/js/Template.ts Normal file
View File

@ -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">&#9776;</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&nbsp;W.&nbsp;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();
});

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

@ -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"] ?? "";
});
});

View File

@ -1,232 +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 `root.querySelector(query)`.
*
* @param query {string} the query string
* @param root {HTMLElement} the element to start searching in, or `undefined` if searching should start in `document`
* @returns {HTMLElement} the element identified by `query` in `root`
*/
const $ = (query, root) => root === undefined ? document.querySelector(query) : root.querySelector(query);
/**
* Alias for `root.querySelectorAll(query)`.
*
* @param query {string} the query string
* @param root {HTMLElement} the element to start searching in, or `undefined` if searching should start in `document`
* @returns {NodeListOf<HTMLElement>} the elements identified by `query` in `root`
*/
const $a = (query, root) => 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 {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
* @param [cb] {Function} the callback to execute on the fetched entries, to prevent the need to re-fetch elsewhere
* @returns {HTMLElement} a base navigation element that will eventually be filled with contents
*/
const nav = function(highlightPath = "", cb = undefined) {
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 => {
if (cb !== undefined) cb(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 class="fwd-nav">` +
`<input id="nav-hamburger-checkbox" type="checkbox" hidden />` +
`<label id="nav-hamburger-label" for="nav-hamburger-checkbox">&#9776;</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 !== "#";
if (entry.entries.length === 0)
return "" +
`<li ${shouldHighlight ? "class=\"currentPage\"" : ""}>` +
`<a href="${entry.link}" ${isExternalLink ? "target=\"_blank\"" : ""}>${entry.name}</a>` +
`</li>`;
const depth = path.split("/").length - 2; // -1 because count parts, then another -1 because of leading `/`
const arrow = depth === 0 ? "&#9662;" : "&#9656;";
return "" +
`<li class="${shouldHighlight ? "currentPage" : ""}">` +
`<a href="${entry.link}">${entry.name} ${arrow}</a>` +
`<ul>${entry.entries.map(it => unpackEntry(it, `${path + entry.name}/`, highlightPath)).join("")}</ul>` +
`</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] {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 = "Florine&nbsp;W.&nbsp;Dekker";
if (authorURL === undefined) authorURL = "https://fwdekker.com/";
if (license === undefined) license = "MIT";
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 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>`,
"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}`;
};
/**
* 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`.
*/
doAfterLoad(() => {
const getMetaProperty = (name) => {
const element = $(`meta[name="${name}"]`);
return element === null ? undefined : element.getAttribute("content");
};
const navTarget = $(getMetaProperty("fwd:nav:target"));
if (navTarget !== null) {
navTarget.parentElement.replaceChild(
nav(getMetaProperty("fwd:nav:highlight-path")),
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
);
}
});
// Export to namespace
window.fwdekker = {stringToHtml, $, $a, doAfterLoad, nav, footer};

View File

@ -1,5 +1,5 @@
<!DOCTYPE html>
<html lang="en" data-theme="light">
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
@ -11,8 +11,9 @@
<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/fwdekker-template/" />
<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>
@ -28,29 +29,55 @@
</p>
</noscript>
<nav id="nav"></nav>
<main class="container container-with-toc">
<main class="container grid-with-sidebar">
<aside>
<nav>
<b class="hidden-no-mobile">Table of contents</b>
<ul>
<li>Item 1</li>
<li>Item 2</li>
</ul>
<hr class="hidden-no-mobile" />
</nav>
Table of contents test
</aside>
<div role="document">
<section>
<header id="header">
<header class="fwd-header">
<hgroup>
<h1><a href=".">Test</a></h1>
<h2>A test page</h2>
</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>
<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>
@ -58,13 +85,31 @@
<!--suppress HtmlUnknownTarget -->
<script src="../../dist/template.js"></script>
<script src="../../dist/storage.js"></script>
<script>
const storage = new fwdekker.storage.MemoryStorage();
storage.setNumber("test-key", 11);
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) + ".");
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>