Compare commits

...

98 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
Florine W. Dekker 80c6437b11 Merge pull request '3.0.0-RC1' (#24) from v3 into master
Reviewed-on: #24
2022-11-19 17:31:52 +01:00
Florine W. Dekker 6965a9d20e
Deprecate mostly-unnecessary header function 2022-11-19 17:26:07 +01:00
Florine W. Dekker 8d5b6ef101
Improve nav, fix some inconsistencies 2022-11-19 17:20:35 +01:00
Florine W. Dekker 04095848c2
Resolve issues with nav size, add toc, etc. 2022-11-19 13:35:14 +01:00
Florine W. Dekker 2702e00880
Experimentally sort-of migrate to pico.css
First step towards fixing #21.
2022-11-19 01:31:37 +01:00
Florine W. Dekker 11f66473f3
Add external link icon
Fixes #22.
2022-11-19 00:03:47 +01:00
Florine W. Dekker a5f43c6436
Add separate classes for template elements
Fixes #23.
2022-11-18 23:43:41 +01:00
Florine W. Dekker 15c0485527
Add input with button style 2022-11-17 09:35:25 +01:00
Florine W. Dekker c44e97cdbd
Add basic TOC code 2022-09-19 16:18:17 +02:00
Florine W. Dekker f488517186
Slightly expand README 2022-09-19 16:03:39 +02:00
Florine W. Dekker bf1bfdc079
Make header title clickable 2022-08-20 20:22:17 +02:00
Florine W. Dekker 9592715507
Add `root` parameters for query functions 2022-08-18 19:23:42 +02:00
Florine W. Dekker 13dcdd8237
Additionally export storage.js from Minesweeper 2022-03-26 19:33:42 +01:00
Florine W. Dekker 55dd99512f
Update repository URL 2022-02-28 17:39:13 +01:00
Florine W. Dekker de1832b736
Update dependencies 2022-02-17 16:00:42 +01:00
Florine W. Dekker 8cae4df5e4
Actually export $a 2021-11-14 11:07:50 +01:00
Florine W. Dekker 05177b4e39
Add $a as alias for querySelectorAll 2021-11-14 11:03:39 +01:00
Florine W. Dekker 7e289927de
Un-justify text 2021-09-11 12:53:38 +02:00
Florine W. Dekker 1f0a7df0db
Do not justify outside of .container 2021-09-11 12:30:59 +02:00
Florine W. Dekker 39cfcb51a5
Justify text by default 2021-09-11 12:27:58 +02:00
Florine W. Dekker e51c026eca
Add issue tracker as VCS to footer 2021-06-18 11:52:24 +02:00
Florine W. Dekker 161a36b308
Add template elements based on meta tags
Fixes #20.
2021-06-08 22:56:51 +02:00
Florine W. Dekker b756a2cc44
Improve styling of noscript to be less intrusive
Also slightly improves a bit of code organisation, but nothing significant there.
2021-05-17 16:08:26 +02:00
Florine W. Dekker db7ab52818
Add callback to reuse fetched nav items
Fixes #15.
2021-05-03 19:20:54 +02:00
Florine W. Dekker 70bbc76aa2
Prevent nav elements from obscuring each other
Fixes #19.
2021-04-30 13:29:25 +02:00
Florine W. Dekker cbcb30aba0
Remove deprecated `showOnPage`
Usage has been removed from all other pages on the website.
2021-04-28 13:55:06 +02:00
Florine W. Dekker 9a3227b43f
Ensure whole nav row is clickable
This is a regression because I accidentally removed the `width: 100%` when adding the hamburger menu.

Fixes #18.
2021-04-28 12:26:01 +02:00
Florine W. Dekker 4f9d7b8311
Add deprecation notice 2021-04-28 12:21:25 +02:00
Florine W. Dekker b5be385ea5
Simplify code for visibility toggle 2021-04-28 12:19:46 +02:00
Florine W. Dekker 46718785af
Ensure .hidden properties are important 2021-04-24 18:45:10 +02:00
Florine W. Dekker a82c43e86f
Add general-purpose .hidden CSS class 2021-04-24 18:07:54 +02:00
Florine W. Dekker 136904e783
Start deprecating inline styles 2021-04-24 16:22:39 +02:00
Florine W. Dekker 45e06510da
Add non-breaking spaces where appropriate 2021-04-23 14:45:56 +02:00
Florine W. Dekker 2e124c0815
Add small border to bottom of navbar 2021-04-22 19:23:13 +02:00
Florine W. Dekker 6ed587c54a
Do not mark subdomains as external links 2021-04-22 18:59:41 +02:00
Florine W. Dekker 7b5590f9ea
Add non-JS hamburger menu
Fixes #17.
2021-04-22 17:00:13 +02:00
Florine W. Dekker 764348ad6d
Deprecate nav.css
No need to update major version because it's not used anywhere.
2021-04-22 16:22:41 +02:00
Florine W. Dekker 86d6783ccc
Rename main exported files
Also add a page for test purposes, and change the layout of the CSS files a bit.
2021-04-21 23:55:54 +02:00
Florine W. Dekker 5476627d41
Add nav.css for nav-only users 2021-04-18 19:09:23 +02:00
Florine W. Dekker 2e73257db6
Ensure high priority for white links in nav 2021-04-16 12:44:38 +02:00
Florine W. Dekker b7c21ad319
Remove unnecessary element classes 2021-04-16 12:39:28 +02:00
Florine W. Dekker 68b5d33f54
Provide defaults for footer params
Fixes #10.
2021-04-15 23:37:33 +02:00
Florine W. Dekker 47f6532171
Use different method for exporting JS 2021-04-15 23:02:30 +02:00
Florine W. Dekker 7b7934dba1
Prepare for centralised deployment 2021-04-15 19:09:42 +02:00
Florine W. Dekker 79b68fdb2c
Invoke `doAfterLoad` even if page is loaded
Fixes #3.
2021-04-13 20:12:52 +02:00
Florine W. Dekker 5043c92d56
Remove wrapping to create wider nav entries
Fixes #9.
2021-04-13 20:04:48 +02:00
Florine W. Dekker e5c92d3d95
Add icon for external links
Fixes #8.
2021-04-13 19:59:51 +02:00
Florine W. Dekker 37fa57b219
Use black instead of grey text
Fixes #7.
2021-04-13 19:49:55 +02:00
Florine W. Dekker e4e5193b70
Correctly highlight level 1 empty navs 2021-03-27 21:51:19 +01:00
Florine W. Dekker 543f65675d
Remove un-minifiable whitespace 2021-03-27 21:51:01 +01:00
Florine W. Dekker a02892414d
Fix interpuncation spacing in footer 2021-03-26 03:08:37 +01:00
Florine W. Dekker 0e4f973553
Rewrite without hyperscript
Fixes #5. Reduces bundle by 6kB.
2021-03-26 02:58:50 +01:00
Florine W. Dekker 55cce0c433
Manually minify normalize library
Saves about 7kB in the deployed size. I couldn't figure out how to automate minification of CSS.
2021-03-26 02:04:01 +01:00
21 changed files with 1345 additions and 453 deletions

View File

@ -6,35 +6,47 @@ module.exports = grunt => {
clean: {
default: ["dist/"],
},
cssmin: {
target: {
files: {
"dist/template.css": "src/main/css/main.css",
},
},
},
focus: {
dev: {
include: ["css", "js"],
include: ["css", "ts"],
},
},
watch: {
css: {
files: ["src/main/**/*.css"],
tasks: ["cssmin"],
},
ts: {
files: ["src/main/**/*.ts"],
tasks: ["webpack:dev"],
},
},
webpack: {
options: {
entry: "./src/main/js/Template.js",
entry: "./src/main/js/Main.ts",
module: {
rules: [
{
test: /\.js$/i,
test: /\.ts$/,
use: "ts-loader",
exclude: /node_modules/,
},
{
test: /\.css$/i,
use: ["style-loader", "css-loader"],
},
],
},
resolve: {
extensions: [".js", ".css"],
extensions: [".ts"],
},
output: {
library: "fwdekker-template",
libraryTarget: "umd",
filename: "index.js",
path: path.resolve(__dirname, "dist"),
}
filename: "template.js",
path: path.resolve(__dirname, "dist/"),
},
},
dev: {
mode: "development",
@ -44,26 +56,17 @@ module.exports = grunt => {
mode: "production",
},
},
watch: {
css: {
files: ["src/main/**/*.css"],
tasks: ["webpack:dev"],
},
js: {
files: ["src/main/**/*.js"],
tasks: ["webpack:dev"],
},
},
});
grunt.loadNpmTasks("grunt-contrib-clean");
grunt.loadNpmTasks("grunt-contrib-cssmin");
grunt.loadNpmTasks("grunt-contrib-watch");
grunt.loadNpmTasks("grunt-focus");
grunt.loadNpmTasks("grunt-webpack");
grunt.registerTask("dev", ["webpack:dev"]);
grunt.registerTask("dev", ["clean", "webpack:dev", "cssmin"]);
grunt.registerTask("dev:server", ["dev", "focus:dev"]);
grunt.registerTask("deploy", ["webpack:deploy"]);
grunt.registerTask("deploy", ["clean", "webpack:deploy", "cssmin"]);
grunt.registerTask("default", ["dev"]);
};

View File

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

View File

@ -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,18 +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
```
### Publishing
```shell script
# Log in to npm
$> npm login
# Push to npm
$> npm publish --access public
```

BIN
package-lock.json generated

Binary file not shown.

View File

@ -1,43 +1,43 @@
{
"name": "@fwdekker/template",
"version": "0.0.22",
"version": "3.6.5",
"description": "The base template for pages on fwdekker.com.",
"author": "Felix W. Dekker (https://fwdekker.com)",
"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": "dist/index.js",
"browser": "template.js",
"files": [
"dist/index.js"
"dist/template.js",
"dist/template.css"
],
"scripts": {
"clean": "grunt clean",
"dev": "grunt dev",
"dev:server": "grunt dev:server",
"deploy": "grunt deploy",
"prepare": "grunt clean deploy"
"deploy": "grunt deploy"
},
"dependencies": {
"hyperscript": "^2.0.2",
"milligram": "^1.4.1",
"normalize.css": "^8.0.1"
"@picocss/pico": "^1.5.10"
},
"devDependencies": {
"css-loader": "^5.1.3",
"grunt": "^1.3.0",
"grunt-cli": "^1.3.2",
"grunt-contrib-clean": "^2.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.2",
"style-loader": "^2.0.0",
"webpack": "^5.27.1",
"webpack-cli": "^4.5.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"
}
}

View File

@ -1,39 +0,0 @@
/* Variables */
: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;
}
/* Base elements */
html, body {
height: 100%;
margin: 0;
padding: 0;
}
#contents {
margin-top: 5rem;
margin-bottom: 5rem;
}
.footer {
margin-bottom: 3rem;
}
/* Custom styling */
header .container {
text-align: center;
}
/* Make arrow next to dropdown visible */
select {
-webkit-appearance: menulist;
-moz-appearance: menulist;
appearance: auto;
}

8
src/main/css/main.css Normal file
View File

@ -0,0 +1,8 @@
@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";

View File

@ -1,86 +0,0 @@
.nav {
margin: 0;
width: 100%;
background-color: var(--fwdekker-theme-color);
font-size: 120%;
--padding: calc(2em / 3);
}
.nav * {
z-index: 10;
vertical-align: middle;
}
.nav a {
display: inline-block;
margin: 0;
padding: calc(var(--padding)) calc(var(--padding));
width: 100%;
color: white;
}
.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);
}
.nav ul {
margin: 0;
padding: 0;
list-style: none;
}
.nav ul li {
display: inline-block;
margin: 0;
padding: 0;
position: relative;
background-color: var(--fwdekker-theme-color);
}
.nav ul li:hover,
.nav ul li:focus-within {
cursor: pointer;
background-color: var(--fwdekker-theme-color-very-dark);
}
.nav li.currentPage {
background-color: var(--fwdekker-theme-color-dark);
}
.nav ul li ul {
display: none;
position: absolute;
left: 0;
}
.nav ul li ul li ul {
left: 100%;
top: 0;
}
.nav ul li:hover > ul,
.nav ul li:focus-within > ul,
.nav ul li ul:hover {
display: block;
}
.nav ul li ul li {
min-width: 7em;
width: 100%;
}

View File

@ -1,90 +0,0 @@
/* 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);
}
.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);
}
.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);
}
.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);
}
.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);
}
.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);
}
pre {
border-left: 0.3rem solid var(--fwdekker-theme-color);
}
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);
}
a {
color: var(--fwdekker-theme-color);
}

View File

@ -0,0 +1,41 @@
/* 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;
}

View File

@ -0,0 +1,90 @@
/* 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: "";
}
/* Hide anything */
.hidden {
display: none !important;
}
@media (min-width: 576px) {
.hidden-no-mobile {
display: none;
}
}
@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);
}
}
/* Noscript */
noscript.fwd-js-notice img {
position: absolute;
}
noscript.fwd-js-notice p {
font-weight: bold;
text-align: center;
}
/* Header */
header.fwd-header a[href="."] {
color: unset;
}
/* Footer */
footer.fwd-footer #fwd-footer-version {
float: right;
}

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

@ -0,0 +1,138 @@
/* Base elements */
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.fwd-nav > ul {
display: flex;
align-items: start;
flex-wrap: wrap;
}
nav.fwd-nav > ul > li {
flex-grow: 0;
flex-basis: 0;
}
nav.fwd-nav li {
position: relative;
width: 100%;
padding: var(--nav-element-spacing-horizontal);
white-space: nowrap;
}
nav.fwd-nav li > :first-child {
display: inline-block;
width: 100%;
margin: 0;
}
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 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;
}
/* z-index */
nav.fwd-nav a {
position: relative;
}
nav.fwd-nav > ul > li > ul {
z-index: 10;
}
/* Colors and optional styling */
nav.fwd-nav,
nav.fwd-nav ul {
background-color: var(--primary);
}
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: 576px) {
nav.fwd-nav #fwd-nav-hamburger-checkbox:not(:checked) ~ ul li:not(:first-child) {
display: none;
}
}

View File

@ -0,0 +1,95 @@
/* 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;

201
src/main/js/Storage.ts Normal file
View File

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

View File

@ -1,179 +0,0 @@
import h from "hyperscript";
import "normalize.css/normalize.css";
import "milligram/dist/milligram.css";
import "../css/common.css";
import "../css/nav.css";
import "../css/overrides.css";
/**
* Alias for `document.querySelector`.
*
* @param q {string} the query string
* @returns {HTMLElement} the element identified by the query string
*/
export 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.
*
* @param fun {function(...*): *} the function to run
*/
export const doAfterLoad = function (fun) {
const oldOnLoad = window.onload || (() => {
});
window.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
*/
export const nav = function (highlightPath = "") {
const base = h("ul",
h("li", h("a", {href: "https://fwdekker.com/"},
h("div.logo", h("img.logo", {src: "https://fwdekker.com/favicon.png"})),
h("b", "FWDekker")
))
);
fetch("https://fwdekker.com/api/nav/")
.then(it => it.json())
.then(json => json.entries.forEach(entry => base.appendChild(unpackEntry(entry, "/", highlightPath))))
.catch(e => {
console.error("Failed to fetch navigation elements", e);
return [];
});
return h("nav.nav", base);
};
/**
* 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 {HTMLElement} the navigation list entry as HTML, described by its children
*/
const unpackEntry = function (entry, path = "/", highlightPath = "") {
const shouldHighlight = highlightPath.startsWith(`${path + entry.name}/`);
if (entry.entries.length === 0)
return h("li",
h("a", {href: entry.link, innerHTML: entry.name}),
{className: shouldHighlight ? "currentPage" : ""}
);
const depth = path.split("/").length - 2; // -1 because count parts, then another -1 because of leading `/`
const arrow = depth === 0 ? "&#9662;" : "&#9656;";
return h("li",
h("a", {href: entry.link, innerHTML: `${entry.name} ${arrow}`}),
h("ul", entry.entries.map(it => unpackEntry(it, `${path + entry.name}/`, highlightPath))),
{className: shouldHighlight ? "currentPage" : ""}
);
};
/**
* 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
*/
export const header = function ({title, description}) {
if (title === undefined && description === undefined)
return h("header.header");
return h("header.header",
h("section.container",
title !== undefined ? h("h1", {innerHTML: title}) : undefined,
description !== undefined ? h("p", h("em", {innerHTML: description})) : undefined
)
);
};
/**
* Creates a footer element with the given data.
*
* @param [author] {string|undefined} the author
* @param [authorURL] {string|undefined} the URL to link the author's name to
* @param [license] {string|undefined} the type of license
* @param [licenseURL] {string|undefined} the URL to the license file
* @param [vcs] {string|undefined} the type of version control
* @param [vcsURL] {string|undefined} the URL to the repository
* @param [version] {string|undefined} the page version
* @param [privacyPolicyURL] {string|null|undefined} the URL to the privacy policy, or `null` if there should be no
* privacy policy, or `undefined` if the default privacy policy should be used
* @returns {HTMLElement} a footer element
*/
export const footer = function (
{
author, authorURL, license, licenseURL, vcs, vcsURL, version,
privacyPolicyURL = undefined
}) {
return h("footer.footer",
h("section.container",
footerLink("Made by ", author, authorURL, ". "),
footerLink("Licensed under the ", license, licenseURL, ". "),
footerLink("Source code available on ", vcs, vcsURL, ". "),
footerLink(
"Consider reading the ",
privacyPolicyURL === null ? undefined : "privacy policy",
privacyPolicyURL === undefined ? "https://fwdekker.com/privacy/" : privacyPolicyURL,
". "
),
h("div", version || "", {style: {"float": "right"}})
)
);
};
/**
* 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|undefined} the text to display, or `undefined` if the returned element should be empty
* @param url {string|undefined} the URL to link the text to, or `undefined` 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 {HTMLElement} a footer link element
*/
const footerLink = function (prefix, text, url, suffix) {
if (text === undefined) return h("span");
return h("span",
h("span", prefix),
url !== undefined
? h("a", text, {href: url})
: h("span", text),
h("span", suffix)
);
};
/**
* Unhides the main element on the page and applies default display styling.
*/
export const showPage = function () {
// Flex-based footer positioning, taken from https://stackoverflow.com/a/12253099
const main = $("main");
main.style.display = "flex";
main.style.flexDirection = "column";
main.style.minHeight = "100%";
$("#contents").style.flex = "1";
}

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

115
src/test/index.html Normal file
View File

@ -0,0 +1,115 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<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" />
<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>
<!--suppress HtmlUnknownTarget -->
<link rel="stylesheet" href="../../dist/template.css" />
</head>
<body>
<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>
<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>
<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>
</main>
<!--suppress HtmlUnknownTarget -->
<script src="../../dist/template.js"></script>
<script>
const {$} = window.fwdekker;
const storage = new window.fwdekker.storage.MemoryStorage();
storage.setNumber("test-key", 11);
console.log("Expected: 11. Actual: " + storage.getNumber("test-key", 0) + ".");
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>

11
tsconfig.json Normal file
View File

@ -0,0 +1,11 @@
{
"compilerOptions": {
"target": "es2019",
"strict": true,
"rootDir": "./src/main/js/",
"outDir": "./dist/js/"
},
"include": [
"src/main/js/**/*.ts"
]
}