├── .github
└── workflows
│ └── static.yml
├── .gitignore
├── .hintrc
├── LICENSE.md
├── README.md
├── eslint.config.cjs
└── public
├── android-chrome-512x512.png
├── apple-touch-icon.png
├── blog
├── archive.html
├── articles
│ ├── 2024-08-17-lets-build-a-blog
│ │ ├── card.html
│ │ ├── example.html
│ │ ├── generator.js
│ │ ├── generator.webp
│ │ ├── image.webp
│ │ └── index.html
│ ├── 2024-08-25-vanilla-entity-encoding
│ │ ├── example1.js
│ │ ├── example2.js
│ │ ├── example3.js
│ │ ├── html.js
│ │ ├── image.webp
│ │ ├── index.html
│ │ └── syntax-highlighting.webp
│ ├── 2024-08-30-poor-mans-signals
│ │ ├── adder.html
│ │ ├── adder.js
│ │ ├── image.webp
│ │ ├── index.html
│ │ ├── preact-example.js
│ │ ├── signals.js
│ │ ├── signals1-use.js
│ │ ├── signals1.js
│ │ ├── signals2-use.js
│ │ ├── signals2.js
│ │ ├── signals3-use.js
│ │ └── signals3.js
│ ├── 2024-09-03-unix-philosophy
│ │ ├── adder.svelte
│ │ ├── bind.js
│ │ ├── bind1.js
│ │ ├── bind2-partial.js
│ │ ├── bind3-partial.js
│ │ ├── bind4-partial.js
│ │ ├── example-bind3
│ │ │ ├── bind.js
│ │ │ ├── example.html
│ │ │ ├── example.js
│ │ │ └── signals.js
│ │ ├── example-combined
│ │ │ ├── adder.js
│ │ │ ├── bind.js
│ │ │ ├── example.html
│ │ │ ├── html.js
│ │ │ └── signals.js
│ │ ├── image.webp
│ │ └── index.html
│ ├── 2024-09-06-how-fast-are-web-components
│ │ ├── image.webp
│ │ └── index.html
│ ├── 2024-09-09-sweet-suspense
│ │ ├── error-boundary-partial.html
│ │ ├── error-boundary.js
│ │ ├── example
│ │ │ ├── components
│ │ │ │ ├── error-boundary.js
│ │ │ │ ├── error-message.js
│ │ │ │ ├── hello-world
│ │ │ │ │ ├── hello-world.js
│ │ │ │ │ └── later.js
│ │ │ │ ├── lazy.js
│ │ │ │ └── suspense.js
│ │ │ ├── index.html
│ │ │ └── index.js
│ │ ├── image.webp
│ │ ├── index.html
│ │ ├── lazy1.js
│ │ ├── lazy2-partial.js
│ │ ├── lazy3-partial.js
│ │ ├── suspense1-partial.html
│ │ ├── suspense1.js
│ │ └── suspense2-partial.js
│ ├── 2024-09-16-life-and-times-of-a-custom-element
│ │ ├── defined
│ │ │ └── example.html
│ │ ├── defined2
│ │ │ └── example.html
│ │ ├── image.webp
│ │ ├── index.html
│ │ ├── observer
│ │ │ └── example.html
│ │ ├── shadowed
│ │ │ └── example.html
│ │ └── undefined
│ │ │ ├── example.css
│ │ │ └── example.html
│ ├── 2024-09-28-unreasonable-effectiveness-of-vanilla-js
│ │ ├── complete
│ │ │ ├── AddTask.js
│ │ │ ├── App.js
│ │ │ ├── TaskList.js
│ │ │ ├── TasksContext.js
│ │ │ ├── index.html
│ │ │ ├── index.js
│ │ │ └── styles.css
│ │ ├── image.webp
│ │ ├── index.html
│ │ └── react
│ │ │ ├── package.json
│ │ │ ├── public
│ │ │ └── index.html
│ │ │ └── src
│ │ │ ├── AddTask.js
│ │ │ ├── App.js
│ │ │ ├── TaskList.js
│ │ │ ├── TasksContext.js
│ │ │ ├── index.js
│ │ │ └── styles.css
│ ├── 2024-09-30-lived-experience
│ │ ├── image.webp
│ │ └── index.html
│ ├── 2024-10-07-needs-more-context
│ │ ├── combined
│ │ │ ├── context-provider.js
│ │ │ ├── context-request.js
│ │ │ ├── index.css
│ │ │ ├── index.html
│ │ │ ├── index.js
│ │ │ ├── theme-context.js
│ │ │ └── theme-provider.js
│ │ ├── context-provider.js
│ │ ├── context-request-1.js
│ │ ├── context-request-2.js
│ │ ├── context-request-3.js
│ │ ├── context-request-4.js
│ │ ├── image.webp
│ │ ├── index.html
│ │ ├── theme-context-fragment.html
│ │ ├── theme-context.js
│ │ └── theme-provider.js
│ ├── 2024-10-20-editing-plain-vanilla
│ │ ├── .hintrc
│ │ ├── devdocs.webp
│ │ ├── eslint.config.cjs
│ │ ├── eslinterror.png
│ │ ├── image.webp
│ │ ├── index.html
│ │ ├── live-preview.webp
│ │ ├── syntax-highlighting.webp
│ │ └── webhinterror.png
│ ├── 2024-12-16-caching-vanilla-sites
│ │ ├── image.webp
│ │ ├── index.html
│ │ ├── plainvanilla.webp
│ │ ├── sw.js
│ │ └── vercel.webp
│ ├── 2025-01-01-new-years-resolve
│ │ ├── example-index.js
│ │ ├── http1.png
│ │ ├── http2.png
│ │ ├── image.webp
│ │ ├── index.html
│ │ ├── layout.js
│ │ └── layout.tsx
│ ├── 2025-04-21-attribute-property-duality
│ │ ├── demo1.html
│ │ ├── demo1.js
│ │ ├── demo2.html
│ │ ├── demo2.js
│ │ ├── demo3.html
│ │ ├── demo3.js
│ │ ├── demo4.html
│ │ ├── demo4.js
│ │ ├── demo5-before.js
│ │ ├── demo5.html
│ │ ├── demo5.js
│ │ ├── image.webp
│ │ └── index.html
│ ├── 2025-05-09-form-control
│ │ ├── demo1
│ │ │ ├── index-partial.txt
│ │ │ ├── index.html
│ │ │ └── input-inline.js
│ │ ├── demo2
│ │ │ ├── index.html
│ │ │ ├── input-inline-partial.js
│ │ │ └── input-inline.js
│ │ ├── demo3
│ │ │ ├── index.html
│ │ │ ├── input-inline-partial.js
│ │ │ └── input-inline.js
│ │ ├── demo4
│ │ │ ├── index.html
│ │ │ ├── input-inline-partial.js
│ │ │ └── input-inline.js
│ │ ├── demo5
│ │ │ ├── index.html
│ │ │ ├── input-inline.css
│ │ │ └── input-inline.js
│ │ ├── demo6
│ │ │ ├── index-partial.txt
│ │ │ ├── index.html
│ │ │ ├── input-inline-partial.js
│ │ │ ├── input-inline.css
│ │ │ └── input-inline.js
│ │ ├── image.webp
│ │ └── index.html
│ └── index.json
├── components
│ ├── blog-archive.js
│ ├── blog-footer.js
│ ├── blog-header.js
│ └── blog-latest-posts.js
├── feed.xml
├── generator.html
├── generator.js
├── index.css
├── index.html
└── index.js
├── components
├── analytics
│ └── analytics.js
├── code-viewer
│ ├── code-viewer.css
│ └── code-viewer.js
└── tab-panel
│ ├── tab-panel.css
│ └── tab-panel.js
├── favicon.ico
├── index.css
├── index.html
├── index.js
├── lib
├── akar-icons
│ ├── LICENSE
│ ├── envelope.svg
│ └── github-fill.svg
├── html.js
└── speed-highlight
│ ├── LICENSE
│ ├── common.js
│ ├── index.js
│ ├── languages
│ ├── css.js
│ ├── html.js
│ ├── js.js
│ ├── js_template_literals.js
│ ├── jsdoc.js
│ ├── json.js
│ ├── log.js
│ ├── plain.js
│ ├── regex.js
│ ├── todo.js
│ ├── ts.js
│ ├── uri.js
│ └── xml.js
│ └── themes
│ ├── default.css
│ ├── github-dark.css
│ └── github-light.css
├── manifest.json
├── pages
├── applications.html
├── components.html
├── examples
│ ├── applications
│ │ ├── counter
│ │ │ ├── components
│ │ │ │ └── counter.js
│ │ │ ├── index.html
│ │ │ └── index.js
│ │ ├── lifting-state-up
│ │ │ ├── components
│ │ │ │ ├── accordion.js
│ │ │ │ └── panel.js
│ │ │ ├── index.css
│ │ │ ├── index.html
│ │ │ ├── index.js
│ │ │ └── react
│ │ │ │ └── App.js
│ │ ├── passing-data-deeply
│ │ │ ├── components
│ │ │ │ ├── button.js
│ │ │ │ ├── panel.js
│ │ │ │ └── theme-context.js
│ │ │ ├── index.css
│ │ │ ├── index.html
│ │ │ ├── index.js
│ │ │ └── lib
│ │ │ │ └── tiny-context.js
│ │ └── single-page
│ │ │ ├── app
│ │ │ └── App.js
│ │ │ ├── components
│ │ │ └── route
│ │ │ │ └── route.js
│ │ │ ├── index.css
│ │ │ ├── index.html
│ │ │ └── index.js
│ ├── components
│ │ ├── adding-children
│ │ │ ├── components
│ │ │ │ ├── avatar.css
│ │ │ │ ├── avatar.js
│ │ │ │ ├── badge.css
│ │ │ │ └── badge.js
│ │ │ ├── index.css
│ │ │ ├── index.html
│ │ │ └── index.js
│ │ ├── advanced
│ │ │ ├── components
│ │ │ │ ├── avatar.css
│ │ │ │ └── avatar.js
│ │ │ ├── index.css
│ │ │ ├── index.html
│ │ │ ├── index.js
│ │ │ └── simple.html
│ │ ├── data
│ │ │ ├── components
│ │ │ │ ├── app.js
│ │ │ │ ├── form.js
│ │ │ │ ├── list-safe.js
│ │ │ │ ├── list.js
│ │ │ │ └── summary.js
│ │ │ ├── index.css
│ │ │ ├── index.html
│ │ │ └── index.js
│ │ ├── shadow-dom
│ │ │ ├── components
│ │ │ │ ├── avatar.css
│ │ │ │ ├── avatar.js
│ │ │ │ ├── badge.css
│ │ │ │ ├── badge.js
│ │ │ │ ├── header.css
│ │ │ │ └── header.js
│ │ │ ├── index.css
│ │ │ ├── index.html
│ │ │ ├── index.js
│ │ │ └── reset.css
│ │ └── simple
│ │ │ ├── hello-world.js
│ │ │ └── index.html
│ ├── sites
│ │ ├── importmap
│ │ │ ├── components
│ │ │ │ └── metrics.js
│ │ │ ├── index.css
│ │ │ ├── index.html
│ │ │ ├── index.js
│ │ │ └── lib
│ │ │ │ ├── dayjs
│ │ │ │ ├── dayjs.min.js
│ │ │ │ ├── module.js
│ │ │ │ └── relativeTime.js
│ │ │ │ └── web-vitals.js
│ │ ├── imports
│ │ │ ├── components
│ │ │ │ └── metrics.js
│ │ │ ├── index.css
│ │ │ ├── index.html
│ │ │ ├── index.js
│ │ │ └── lib
│ │ │ │ ├── dayjs
│ │ │ │ ├── dayjs.min.js
│ │ │ │ └── relativeTime.js
│ │ │ │ ├── imports.js
│ │ │ │ └── web-vitals.js
│ │ └── page
│ │ │ ├── example.html
│ │ │ ├── example2.html
│ │ │ └── index.js
│ └── styling
│ │ ├── replacing-css-modules
│ │ ├── nextjs
│ │ │ ├── layout.tsx
│ │ │ └── styles.module.css
│ │ └── vanilla
│ │ │ ├── layout.js
│ │ │ └── styles.css
│ │ ├── scoping-prefixed
│ │ ├── components
│ │ │ └── example
│ │ │ │ ├── example.css
│ │ │ │ ├── example.js
│ │ │ │ └── example_nested.css
│ │ ├── index.css
│ │ ├── index.html
│ │ └── index.js
│ │ └── scoping-shadowed
│ │ ├── components
│ │ └── example
│ │ │ ├── example.css
│ │ │ └── example.js
│ │ ├── index.html
│ │ └── index.js
├── sites.html
└── styling.html
├── robots.txt
├── sitemap.txt
├── styles
├── global.css
├── reset.css
└── variables.css
└── tests
├── imports-test.js
├── index.html
├── index.js
├── lib
├── @testing-library
│ └── dom.umd.js
└── mocha
│ ├── chai.js
│ ├── mocha.css
│ └── mocha.js
└── tabpanel.test.js
/.github/workflows/static.yml:
--------------------------------------------------------------------------------
1 | # Simple workflow for deploying static content to GitHub Pages
2 | name: Deploy static content to Pages
3 |
4 | on:
5 | # Runs on pushes targeting the default branch
6 | push:
7 | branches: ["main"]
8 |
9 | # Allows you to run this workflow manually from the Actions tab
10 | workflow_dispatch:
11 |
12 | # Sets permissions of the GITHUB_TOKEN to allow deployment to GitHub Pages
13 | permissions:
14 | contents: read
15 | pages: write
16 | id-token: write
17 |
18 | # Allow only one concurrent deployment, skipping runs queued between the run in-progress and latest queued.
19 | # However, do NOT cancel in-progress runs as we want to allow these production deployments to complete.
20 | concurrency:
21 | group: "pages"
22 | cancel-in-progress: false
23 |
24 | jobs:
25 | # Single deploy job since we're just deploying
26 | deploy:
27 | environment:
28 | name: github-pages
29 | url: ${{ steps.deployment.outputs.page_url }}
30 | runs-on: ubuntu-latest
31 | steps:
32 | - name: Checkout
33 | uses: actions/checkout@v4
34 | - name: Setup Pages
35 | uses: actions/configure-pages@v5
36 | - name: Create analytics file
37 | run: echo "${{ vars.CLOUDFLARE_ANALYTICS }}" > public/analytics.template
38 | - name: Upload artifact
39 | uses: actions/upload-pages-artifact@v3
40 | with:
41 | # Deploy public folder only
42 | path: './public'
43 | - name: Deploy to GitHub Pages
44 | id: deployment
45 | uses: actions/deploy-pages@v4
46 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | .DS_Store
2 | *.pem
3 | public/analytics.template
4 |
--------------------------------------------------------------------------------
/.hintrc:
--------------------------------------------------------------------------------
1 | {
2 | "extends": [
3 | "development"
4 | ],
5 | "hints": {
6 | "compat-api/html": [
7 | "default",
8 | {
9 | "ignore": [
10 | "iframe[loading]"
11 | ]
12 | }
13 | ],
14 | "no-inline-styles": "off"
15 | }
16 | }
--------------------------------------------------------------------------------
/LICENSE.md:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2024 Joeri Sebrechts
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
6 |
7 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
8 |
9 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
10 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Plain Vanilla
2 |
3 | A website demonstrating how to do web development using only vanilla techniques: no tools, no frameworks, just the browser and vanilla web code. The site itself is also built in this way.
4 |
5 | ## Running
6 |
7 | Run the `public/` folder as a static website:
8 |
9 | - node: `npx http-server public -c-1`
10 | - php: `php -S localhost:8000 -t public`
11 | - python: `python3 -m http.server 8000 --directory public`
12 |
13 | Or use the VS Code Live Preview extension to show `public/index.html`.
14 |
15 | ## Contributing
16 |
17 | Issues or PR's welcome!
18 |
19 | ## Other resources
20 |
21 | These are some other resources demonstrating #notools web development techniques.
22 |
23 | - [MDN Learn Web Development](https://developer.mozilla.org/en-US/docs/Learn): a vanilla web development learning path
24 | - [Odin Project Foundations](https://www.theodinproject.com/paths/foundations/courses/foundations): a vanilla web development course
25 | - [create-react-app-zero](https://github.com/jsebrech/create-react-app-zero): another project of mine, a no-tools version of create-react-app, to be able to use React without frills
26 | - [HEX: a No Framework Approach to Building Modern Web Apps](https://medium.com/@metapgmr/hex-a-no-framework-approach-to-building-modern-web-apps-e43f74190b9c): a React-like approach based on vanilla web development techniques
27 | - [plainJS](https://plainjs.com/): a collection of vanilla javascript functions and plugins to replace the use of jQuery
28 | - [Web Dev Toolkit](https://gomakethings.com/toolkit/): a collection of vanilla helper functions, boilerplates and libraries
29 | - [The Modern JavaScipt Tutorial](https://javascript.info/): a step-by-step tutorial to learn vanilla JavaScript
30 |
--------------------------------------------------------------------------------
/eslint.config.cjs:
--------------------------------------------------------------------------------
1 | /* eslint-disable no-undef */
2 | const globals = require("globals");
3 | const js = require("@eslint/js");
4 |
5 | module.exports = [
6 | js.configs.recommended,
7 | {
8 | languageOptions: {
9 | globals: {
10 | ...globals.browser,
11 | ...globals.mocha
12 | },
13 | ecmaVersion: 2022,
14 | sourceType: "module",
15 | }
16 | },
17 | {
18 | ignores: [
19 | "public/blog/articles/",
20 | "**/lib/",
21 | "**/react/",
22 | ]
23 | }
24 | ];
--------------------------------------------------------------------------------
/public/android-chrome-512x512.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/jsebrech/plainvanilla/48b88bbfc51210228cb7c5b8dd797a208c663bc5/public/android-chrome-512x512.png
--------------------------------------------------------------------------------
/public/apple-touch-icon.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/jsebrech/plainvanilla/48b88bbfc51210228cb7c5b8dd797a208c663bc5/public/apple-touch-icon.png
--------------------------------------------------------------------------------
/public/blog/archive.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | Plain Vanilla Blog
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 | Please enable JavaScript to view this page correctly.
14 |
24 |
25 | Archive
26 | Please enable scripting to see the archives.
27 |
28 |
29 |
30 |
31 |
32 |
33 |
--------------------------------------------------------------------------------
/public/blog/articles/2024-08-17-lets-build-a-blog/card.html:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/public/blog/articles/2024-08-17-lets-build-a-blog/example.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | A spiffy title!
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 | A spiffy title!
13 | Malkovich
14 |
15 |
16 | Article text goes here ...
17 |
18 |
19 |
20 |
21 |
--------------------------------------------------------------------------------
/public/blog/articles/2024-08-17-lets-build-a-blog/generator.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/jsebrech/plainvanilla/48b88bbfc51210228cb7c5b8dd797a208c663bc5/public/blog/articles/2024-08-17-lets-build-a-blog/generator.webp
--------------------------------------------------------------------------------
/public/blog/articles/2024-08-17-lets-build-a-blog/image.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/jsebrech/plainvanilla/48b88bbfc51210228cb7c5b8dd797a208c663bc5/public/blog/articles/2024-08-17-lets-build-a-blog/image.webp
--------------------------------------------------------------------------------
/public/blog/articles/2024-08-25-vanilla-entity-encoding/example1.js:
--------------------------------------------------------------------------------
1 | class MyComponent extends HTMLElement {
2 | connectedCallback() {
3 | const btn = `${this.getAttribute('foo')} `;
4 | this.innerHTML = `
5 | ${this.getAttribute('bar')}
6 |
7 | ${this.getAttribute('xyzzy')}
8 | ${btn}
9 |
10 | `;
11 | }
12 | }
13 | customElements.define('my-component', MyComponent);
--------------------------------------------------------------------------------
/public/blog/articles/2024-08-25-vanilla-entity-encoding/example2.js:
--------------------------------------------------------------------------------
1 | function htmlEncode(s) {
2 | return s.replace(/[&<>'"]/g,
3 | tag => ({
4 | '&': '&',
5 | '<': '<',
6 | '>': '>',
7 | "'": ''',
8 | '"': '"'
9 | }[tag]))
10 | }
11 |
12 | class MyComponent extends HTMLElement {
13 | connectedCallback() {
14 | const btn = `${htmlEncode(this.getAttribute('foo'))} `;
15 | this.innerHTML = `
16 | ${htmlEncode(this.getAttribute('bar'))}
17 |
18 | ${htmlEncode(this.getAttribute('xyzzy'))}
19 | ${btn}
20 |
21 | `;
22 | }
23 | }
24 | customElements.define('my-component', MyComponent);
--------------------------------------------------------------------------------
/public/blog/articles/2024-08-25-vanilla-entity-encoding/example3.js:
--------------------------------------------------------------------------------
1 | import { html } from './html.js';
2 |
3 | class MyComponent extends HTMLElement {
4 | connectedCallback() {
5 | const btn = html`${this.getAttribute('foo')} `;
6 | this.innerHTML = html`
7 | ${this.getAttribute('bar')}
8 |
9 | ${this.getAttribute('xyzzy')}
10 | ${btn}
11 |
12 | `;
13 | }
14 | }
15 | customElements.define('my-component', MyComponent);
--------------------------------------------------------------------------------
/public/blog/articles/2024-08-25-vanilla-entity-encoding/html.js:
--------------------------------------------------------------------------------
1 | class Html extends String { }
2 |
3 | /**
4 | * tag a string as html not to be encoded
5 | * @param {string} str
6 | * @returns {string}
7 | */
8 | export const htmlRaw = str => new Html(str);
9 |
10 | /**
11 | * entity encode a string as html
12 | * @param {*} value The value to encode
13 | * @returns {string}
14 | */
15 | export const htmlEncode = (value) => {
16 | // avoid double-encoding the same string
17 | if (value instanceof Html) {
18 | return value;
19 | } else {
20 | // https://stackoverflow.com/a/57448862/20980
21 | return htmlRaw(
22 | String(value).replace(/[&<>'"]/g,
23 | tag => ({
24 | '&': '&',
25 | '<': '<',
26 | '>': '>',
27 | "'": ''',
28 | '"': '"'
29 | }[tag]))
30 | );
31 | }
32 | }
33 |
34 | /**
35 | * html tagged template literal, auto-encodes entities
36 | */
37 | export const html = (strings, ...values) =>
38 | htmlRaw(String.raw({ raw: strings }, ...values.map(htmlEncode)));
39 |
--------------------------------------------------------------------------------
/public/blog/articles/2024-08-25-vanilla-entity-encoding/image.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/jsebrech/plainvanilla/48b88bbfc51210228cb7c5b8dd797a208c663bc5/public/blog/articles/2024-08-25-vanilla-entity-encoding/image.webp
--------------------------------------------------------------------------------
/public/blog/articles/2024-08-25-vanilla-entity-encoding/syntax-highlighting.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/jsebrech/plainvanilla/48b88bbfc51210228cb7c5b8dd797a208c663bc5/public/blog/articles/2024-08-25-vanilla-entity-encoding/syntax-highlighting.webp
--------------------------------------------------------------------------------
/public/blog/articles/2024-08-30-poor-mans-signals/adder.html:
--------------------------------------------------------------------------------
1 |
2 |
3 | Adder example
4 |
5 |
6 |
7 |
10 |
11 |
12 |
--------------------------------------------------------------------------------
/public/blog/articles/2024-08-30-poor-mans-signals/adder.js:
--------------------------------------------------------------------------------
1 | import { signal, computed } from './signals.js';
2 |
3 | customElements.define('x-adder', class extends HTMLElement {
4 | a = signal(1);
5 | b = signal(2);
6 | result = computed((a, b) => `${a} + ${b} = ${+a + +b}`, [this.a, this.b]);
7 |
8 | connectedCallback() {
9 | if (this.querySelector('input')) return;
10 |
11 | this.innerHTML = `
12 |
13 |
14 |
15 | `;
16 | this.result.effect(
17 | () => this.querySelector('p').textContent = this.result);
18 | this.addEventListener('input',
19 | e => this[e.target.name].value = e.target.value);
20 | }
21 | });
22 |
--------------------------------------------------------------------------------
/public/blog/articles/2024-08-30-poor-mans-signals/image.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/jsebrech/plainvanilla/48b88bbfc51210228cb7c5b8dd797a208c663bc5/public/blog/articles/2024-08-30-poor-mans-signals/image.webp
--------------------------------------------------------------------------------
/public/blog/articles/2024-08-30-poor-mans-signals/preact-example.js:
--------------------------------------------------------------------------------
1 | import { signal, computed, effect } from "@preact/signals";
2 |
3 | const name = signal("Jane");
4 | const surname = signal("Doe");
5 | const fullName = computed(() => `${name.value} ${surname.value}`);
6 |
7 | // Logs name every time it changes:
8 | effect(() => console.log(fullName.value));
9 | // Logs: "Jane Doe"
10 |
11 | // Updating `name` updates `fullName`, which triggers the effect again:
12 | name.value = "John";
13 | // Logs: "John Doe"
14 |
--------------------------------------------------------------------------------
/public/blog/articles/2024-08-30-poor-mans-signals/signals.js:
--------------------------------------------------------------------------------
1 | export class Signal extends EventTarget {
2 | #value;
3 | get value () { return this.#value; }
4 | set value (value) {
5 | if (this.#value === value) return;
6 | this.#value = value;
7 | this.dispatchEvent(new CustomEvent('change'));
8 | }
9 |
10 | constructor (value) {
11 | super();
12 | this.#value = value;
13 | }
14 |
15 | effect(fn) {
16 | fn();
17 | this.addEventListener('change', fn);
18 | return () => this.removeEventListener('change', fn);
19 | }
20 |
21 | valueOf () { return this.#value; }
22 | toString () { return String(this.#value); }
23 | }
24 |
25 | export class Computed extends Signal {
26 | constructor (fn, deps) {
27 | super(fn(...deps));
28 | for (const dep of deps) {
29 | if (dep instanceof Signal)
30 | dep.addEventListener('change', () => this.value = fn(...deps));
31 | }
32 | }
33 | }
34 |
35 | export const signal = _ => new Signal(_);
36 | export const computed = (fn, deps) => new Computed(fn, deps);
37 |
--------------------------------------------------------------------------------
/public/blog/articles/2024-08-30-poor-mans-signals/signals1-use.js:
--------------------------------------------------------------------------------
1 | const name = new Signal('Jane');
2 | name.addEventListener('change', () => console.log(name.value));
3 | name.value = 'John';
4 | // Logs: John
--------------------------------------------------------------------------------
/public/blog/articles/2024-08-30-poor-mans-signals/signals1.js:
--------------------------------------------------------------------------------
1 | class Signal extends EventTarget {
2 | #value;
3 | get value () { return this.#value; }
4 | set value (value) {
5 | if (this.#value === value) return;
6 | this.#value = value;
7 | this.dispatchEvent(new CustomEvent('change'));
8 | }
9 |
10 | constructor (value) {
11 | super();
12 | this.#value = value;
13 | }
14 | }
--------------------------------------------------------------------------------
/public/blog/articles/2024-08-30-poor-mans-signals/signals2-use.js:
--------------------------------------------------------------------------------
1 | const name = signal('Jane');
2 | name.effect(() => console.log(name.value));
3 | // Logs: Jane
4 | name.value = 'John';
5 | // Logs: John
--------------------------------------------------------------------------------
/public/blog/articles/2024-08-30-poor-mans-signals/signals2.js:
--------------------------------------------------------------------------------
1 | class Signal extends EventTarget {
2 | #value;
3 | get value () { return this.#value; }
4 | set value (value) {
5 | if (this.#value === value) return;
6 | this.#value = value;
7 | this.dispatchEvent(new CustomEvent('change'));
8 | }
9 |
10 | constructor (value) {
11 | super();
12 | this.#value = value;
13 | }
14 |
15 | effect(fn) {
16 | fn();
17 | this.addEventListener('change', fn);
18 | return () => this.removeEventListener('change', fn);
19 | }
20 |
21 | valueOf () { return this.#value; }
22 | toString () { return String(this.#value); }
23 | }
24 |
25 | const signal = _ => new Signal(_);
26 |
--------------------------------------------------------------------------------
/public/blog/articles/2024-08-30-poor-mans-signals/signals3-use.js:
--------------------------------------------------------------------------------
1 | const name = signal('Jane');
2 | const surname = signal('Doe');
3 | const fullName = computed(() => `${name} ${surname}`, [name, surname]);
4 | // Logs name every time it changes:
5 | fullName.effect(() => console.log(fullName.value));
6 | // -> Jane Doe
7 |
8 | // Updating `name` updates `fullName`, which triggers the effect again:
9 | name.value = 'John';
10 | // -> John Doe
11 |
--------------------------------------------------------------------------------
/public/blog/articles/2024-08-30-poor-mans-signals/signals3.js:
--------------------------------------------------------------------------------
1 | class Computed extends Signal {
2 | constructor (fn, deps) {
3 | super(fn(...deps));
4 | for (const dep of deps) {
5 | if (dep instanceof Signal)
6 | dep.addEventListener('change', () => this.value = fn(...deps));
7 | }
8 | }
9 | }
10 |
11 | const computed = (fn, deps) => new Computed(fn, deps);
12 |
--------------------------------------------------------------------------------
/public/blog/articles/2024-09-03-unix-philosophy/adder.svelte:
--------------------------------------------------------------------------------
1 |
5 |
6 |
7 |
8 |
9 | {a} + {b} = {a + b}
--------------------------------------------------------------------------------
/public/blog/articles/2024-09-03-unix-philosophy/bind1.js:
--------------------------------------------------------------------------------
1 | export const bind = (template) => {
2 | const fragment = template.content.cloneNode(true);
3 | // iterate over all nodes in the fragment
4 | const iterator = document.createNodeIterator(
5 | fragment,
6 | NodeFilter.SHOW_ELEMENT,
7 | {
8 | // reject any node that is not an HTML element
9 | acceptNode: (node) => {
10 | if (!(node instanceof HTMLElement))
11 | return NodeFilter.FILTER_REJECT;
12 | return NodeFilter.FILTER_ACCEPT;
13 | },
14 | }
15 | );
16 | let node;
17 | while (node = iterator.nextNode()) {
18 | if (!node) return;
19 | const elem = node;
20 | for (const attr of Array(...node.attributes)) {
21 | // check for event binding directive
22 | if (attr.name.startsWith('@')) {
23 |
24 | // TODO: bind event ...
25 |
26 | elem.removeAttributeNode(attr);
27 | // check for property/attribute binding directive
28 | } else if (attr.name.startsWith(':')) {
29 |
30 | // TODO: bind data ...
31 |
32 | elem.removeAttributeNode(attr);
33 | }
34 | }
35 | }
36 | return fragment;
37 | }
--------------------------------------------------------------------------------
/public/blog/articles/2024-09-03-unix-philosophy/bind2-partial.js:
--------------------------------------------------------------------------------
1 | export const bind = (template, target) => {
2 | if (!template.content) {
3 | const text = template;
4 | template = document.createElement('template');
5 | template.innerHTML = text;
6 | }
7 | const fragment = template.content.cloneNode(true);
8 | // ...
9 | }
--------------------------------------------------------------------------------
/public/blog/articles/2024-09-03-unix-philosophy/bind3-partial.js:
--------------------------------------------------------------------------------
1 | // check for custom event listener attributes
2 | if (attr.name.startsWith('@')) {
3 | const event = attr.name.slice(1);
4 | const property = attr.value;
5 | let listener;
6 | // if we're binding the event to a function, call it directly
7 | if (typeof target[property] === 'function') {
8 | listener = target[property].bind(target);
9 | // if we're binding to a signal, set the signal's value
10 | } else if (typeof target[property] === 'object' &&
11 | typeof target[property].value !== 'undefined') {
12 | listener = e => target[property].value = e.target.value;
13 | // fallback: assume we're binding to a property, set the property's value
14 | } else {
15 | listener = e => target[property] = e.target.value;
16 | }
17 | elem.addEventListener(event, listener);
18 | // remove (non-standard) attribute from element
19 | elem.removeAttributeNode(attr);
20 | }
--------------------------------------------------------------------------------
/public/blog/articles/2024-09-03-unix-philosophy/bind4-partial.js:
--------------------------------------------------------------------------------
1 | // ...
2 | if (attr.name.startsWith(':')) {
3 | // extract the name and value of the attribute/property
4 | let name = attr.name.slice(1);
5 | const property = getPropertyForAttribute(name, target);
6 | const setter = property ?
7 | () => elem[property] = target[attr.value] :
8 | () => elem.setAttribute(name, target[attr.value]);
9 | setter();
10 | // if we're binding to a signal, listen to updates
11 | if (target[attr.value]?.effect) {
12 | target[attr.value].effect(setter);
13 | // if we're binding to a property, listen to the target's updates
14 | } else if (target.addEventListener) {
15 | target.addEventListener('change', setter);
16 | }
17 | // remove (non-standard) attribute from element
18 | elem.removeAttributeNode(attr);
19 | }
20 | // ...
21 |
22 | function getPropertyForAttribute(name, obj) {
23 | switch (name.toLowerCase()) {
24 | case 'text': case 'textcontent':
25 | return 'textContent';
26 | case 'html': case 'innerhtml':
27 | return 'innerHTML';
28 | default:
29 | for (let prop of Object.getOwnPropertyNames(obj)) {
30 | if (prop.toLowerCase() === name.toLowerCase()) {
31 | return prop;
32 | }
33 | }
34 | }
35 | }
--------------------------------------------------------------------------------
/public/blog/articles/2024-09-03-unix-philosophy/example-bind3/example.html:
--------------------------------------------------------------------------------
1 |
2 |
3 | Binding example
4 |
5 |
6 |
7 |
11 |
12 |
13 |
--------------------------------------------------------------------------------
/public/blog/articles/2024-09-03-unix-philosophy/example-bind3/example.js:
--------------------------------------------------------------------------------
1 | import { bind } from './bind.js';
2 | import { signal } from './signals.js';
3 |
4 | customElements.define('x-example', class Example extends HTMLElement {
5 |
6 | set a(value) {
7 | this.setAttribute('a', value);
8 | this.querySelector('label[for=a] span').textContent = value;
9 | }
10 | set b(value) {
11 | this.setAttribute('b', value);
12 | this.querySelector('label[for=b] span').textContent = value;
13 | }
14 | c = signal('');
15 |
16 | connectedCallback() {
17 | this.append(bind(`
18 |
19 |
20 | A =
21 |
22 |
23 |
24 | B =
25 |
26 |
27 |
28 | C =
29 |
30 | Add
31 | Result:
32 | `, this));
33 | this.c.effect(() =>
34 | this.querySelector('label[for=c] span').textContent = this.c);
35 | }
36 |
37 | onInputA (e) {
38 | this.a = e.target.value;
39 | }
40 |
41 | onClick() {
42 | this.querySelector('#result').textContent =
43 | +this.getAttribute('a') + +this.getAttribute('b') + +this.c;
44 | }
45 | });
46 |
--------------------------------------------------------------------------------
/public/blog/articles/2024-09-03-unix-philosophy/example-bind3/signals.js:
--------------------------------------------------------------------------------
1 | export class Signal extends EventTarget {
2 | #value;
3 | get value () { return this.#value; }
4 | set value (value) {
5 | if (this.#value === value) return;
6 | this.#value = value;
7 | this.dispatchEvent(new CustomEvent('change'));
8 | }
9 |
10 | constructor (value) {
11 | super();
12 | this.#value = value;
13 | }
14 |
15 | effect(fn) {
16 | fn();
17 | this.addEventListener('change', fn);
18 | return () => this.removeEventListener('change', fn);
19 | }
20 |
21 | valueOf () { return this.#value; }
22 | toString () { return String(this.#value); }
23 | }
24 |
25 | export class Computed extends Signal {
26 | constructor (fn, deps) {
27 | super(fn(...deps));
28 | for (const dep of deps) {
29 | if (dep instanceof Signal)
30 | dep.addEventListener('change', () => this.value = fn(...deps));
31 | }
32 | }
33 | }
34 |
35 | export const signal = _ => new Signal(_);
36 | export const computed = (fn, deps) => new Computed(fn, deps);
37 |
--------------------------------------------------------------------------------
/public/blog/articles/2024-09-03-unix-philosophy/example-combined/adder.js:
--------------------------------------------------------------------------------
1 | import { bind } from './bind.js';
2 | import { signal, computed } from './signals.js';
3 | import { html } from './html.js';
4 |
5 | customElements.define('x-adder', class Adder extends HTMLElement {
6 | a = signal();
7 | b = signal();
8 | result = computed(() =>
9 | html`${+this.a} + ${+this.b} = ${+this.a + +this.b}`, [this.a, this.b]);
10 |
11 | connectedCallback() {
12 | this.a.value ??= this.getAttribute('a') || 0;
13 | this.b.value ??= this.getAttribute('b') || 0;
14 | this.append(bind(html`
15 |
16 |
17 |
18 | `, this));
19 | }
20 | });
21 |
--------------------------------------------------------------------------------
/public/blog/articles/2024-09-03-unix-philosophy/example-combined/example.html:
--------------------------------------------------------------------------------
1 |
2 |
3 | Binding example
4 |
5 |
6 |
7 |
11 |
12 |
13 |
--------------------------------------------------------------------------------
/public/blog/articles/2024-09-03-unix-philosophy/example-combined/html.js:
--------------------------------------------------------------------------------
1 | class Html extends String { }
2 |
3 | /**
4 | * tag a string as html not to be encoded
5 | * @param {string} str
6 | * @returns {string}
7 | */
8 | export const htmlRaw = str => new Html(str);
9 |
10 | /**
11 | * entity encode a string as html
12 | * @param {*} value The value to encode
13 | * @returns {string}
14 | */
15 | export const htmlEncode = (value) => {
16 | // avoid double-encoding the same string
17 | if (value instanceof Html) {
18 | return value;
19 | } else {
20 | // https://stackoverflow.com/a/57448862/20980
21 | return htmlRaw(
22 | String(value).replace(/[&<>'"]/g,
23 | tag => ({
24 | '&': '&',
25 | '<': '<',
26 | '>': '>',
27 | "'": ''',
28 | '"': '"'
29 | }[tag]))
30 | );
31 | }
32 | }
33 |
34 | /**
35 | * html tagged template literal, auto-encodes entities
36 | */
37 | export const html = (strings, ...values) =>
38 | htmlRaw(String.raw({ raw: strings }, ...values.map(htmlEncode)));
39 |
--------------------------------------------------------------------------------
/public/blog/articles/2024-09-03-unix-philosophy/example-combined/signals.js:
--------------------------------------------------------------------------------
1 | export class Signal extends EventTarget {
2 | #value;
3 | get value () { return this.#value; }
4 | set value (value) {
5 | if (this.#value === value) return;
6 | this.#value = value;
7 | this.dispatchEvent(new CustomEvent('change'));
8 | }
9 |
10 | constructor (value) {
11 | super();
12 | this.#value = value;
13 | }
14 |
15 | effect(fn) {
16 | fn();
17 | this.addEventListener('change', fn);
18 | return () => this.removeEventListener('change', fn);
19 | }
20 |
21 | valueOf () { return this.#value; }
22 | toString () { return String(this.#value); }
23 | }
24 |
25 | export class Computed extends Signal {
26 | constructor (fn, deps) {
27 | super(fn(...deps));
28 | for (const dep of deps) {
29 | if (dep instanceof Signal)
30 | dep.addEventListener('change', () => this.value = fn(...deps));
31 | }
32 | }
33 | }
34 |
35 | export const signal = _ => new Signal(_);
36 | export const computed = (fn, deps) => new Computed(fn, deps);
37 |
--------------------------------------------------------------------------------
/public/blog/articles/2024-09-03-unix-philosophy/image.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/jsebrech/plainvanilla/48b88bbfc51210228cb7c5b8dd797a208c663bc5/public/blog/articles/2024-09-03-unix-philosophy/image.webp
--------------------------------------------------------------------------------
/public/blog/articles/2024-09-06-how-fast-are-web-components/image.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/jsebrech/plainvanilla/48b88bbfc51210228cb7c5b8dd797a208c663bc5/public/blog/articles/2024-09-06-how-fast-are-web-components/image.webp
--------------------------------------------------------------------------------
/public/blog/articles/2024-09-09-sweet-suspense/error-boundary-partial.html:
--------------------------------------------------------------------------------
1 |
2 | Something went wrong
3 |
4 | Loading...
5 |
6 |
7 |
--------------------------------------------------------------------------------
/public/blog/articles/2024-09-09-sweet-suspense/error-boundary.js:
--------------------------------------------------------------------------------
1 | export class ErrorBoundary extends HTMLElement {
2 |
3 | static showError(sender, error) {
4 | if (!error) throw new Error('ErrorBoundary.showError: expected two arguments but got one');
5 | const boundary = sender.closest('x-error-boundary');
6 | if (boundary) {
7 | boundary.error = error;
8 | } else {
9 | console.error('unable to find x-error-boundary to show error');
10 | console.error(error);
11 | }
12 | }
13 |
14 | #error;
15 | #errorSlot;
16 | #contentSlot;
17 |
18 | get error() {
19 | return this.#error;
20 | }
21 |
22 | set error(error) {
23 | if (!this.#errorSlot) return;
24 | this.#error = error;
25 | this.#errorSlot.style.display = error ? 'contents' : 'none';
26 | this.#contentSlot.style.display = !error ? 'contents' : 'none';
27 | if (error) {
28 | this.#errorSlot.assignedElements().forEach(element => {
29 | if (Object.hasOwn(element, 'error')) {
30 | element.error = error;
31 | } else {
32 | element.setAttribute('error', error?.message || error);
33 | }
34 | });
35 | this.dispatchEvent(new CustomEvent('error', { detail: error }));
36 | }
37 | }
38 |
39 | constructor() {
40 | super();
41 | this.attachShadow({ mode: 'open' });
42 |
43 | this.#errorSlot = document.createElement('slot');
44 | this.#errorSlot.style.display = 'none';
45 | this.#errorSlot.name = 'error';
46 | // default error message
47 | this.#errorSlot.textContent = 'Something went wrong.';
48 | this.#contentSlot = document.createElement('slot');
49 | this.shadowRoot.append(this.#errorSlot, this.#contentSlot);
50 | }
51 |
52 | reset() {
53 | this.error = null;
54 | }
55 |
56 | connectedCallback() {
57 | this.style.display = 'contents';
58 | }
59 | }
60 | customElements.define('x-error-boundary', ErrorBoundary);
61 |
--------------------------------------------------------------------------------
/public/blog/articles/2024-09-09-sweet-suspense/example/components/error-message.js:
--------------------------------------------------------------------------------
1 | class ErrorMessage extends HTMLElement {
2 | connectedCallback() {
3 | this.update();
4 | }
5 |
6 | static get observedAttributes() {
7 | return ['error'];
8 | }
9 |
10 | attributeChangedCallback() {
11 | this.update();
12 | }
13 |
14 | update() {
15 | const errorMsg = this.getAttribute('error') || '';
16 | this.textContent = errorMsg;
17 | }
18 | }
19 |
20 | export const registerErrorMessage = () => customElements.define('x-error-message', ErrorMessage);
21 |
--------------------------------------------------------------------------------
/public/blog/articles/2024-09-09-sweet-suspense/example/components/hello-world/hello-world.js:
--------------------------------------------------------------------------------
1 | import { Suspense } from '../suspense.js';
2 | import { later } from './later.js';
3 |
4 | class HelloWorldComponent extends HTMLElement {
5 | connectedCallback() {
6 | this.innerHTML = `
7 | Hello world!
8 | Load
9 | Load with error
10 | `;
11 | const btnLoad = this.querySelector('button#load');
12 | btnLoad.onclick = () => {
13 | // simulate loading of data
14 | Suspense.waitFor(this, later(1000));
15 | };
16 | const btnError = this.querySelector('button#error');
17 | btnError.onclick = () => {
18 | // simulate loading of data that ends in an error
19 | Suspense.waitFor(this, later(1000).then(() => { throw new Error('An error, as expected.'); }));
20 | }
21 | }
22 | }
23 |
24 | export default function register() {
25 | customElements.define('x-hello-world', HelloWorldComponent);
26 | }
27 |
--------------------------------------------------------------------------------
/public/blog/articles/2024-09-09-sweet-suspense/example/components/hello-world/later.js:
--------------------------------------------------------------------------------
1 | export function later(delay) {
2 | return new Promise(function(resolve) {
3 | setTimeout(resolve, delay);
4 | });
5 | }
--------------------------------------------------------------------------------
/public/blog/articles/2024-09-09-sweet-suspense/example/components/lazy.js:
--------------------------------------------------------------------------------
1 | import { Suspense } from './suspense.js';
2 |
3 | /**
4 | * A vanilla version of React's lazy() function
5 | * inspired by https://css-tricks.com/an-approach-to-lazy-loading-custom-elements/
6 | *
7 | * Usage:
8 | *
9 | * Will load default function from ./components//.js and execute it.
10 | * Only direct children are lazy-loaded, and only on initial DOM insert.
11 | *
12 | * Pass the root attribute to modify the path to load relative to the current document.
13 | *
14 | *
15 | * Put the lazy-path attribute on a custom element to specify the path to the JS file to load.
16 | *
17 | * @license MIT
18 | */
19 | class Lazy extends HTMLElement {
20 | connectedCallback() {
21 | this.style.display = 'contents';
22 | this.#loadLazy();
23 | }
24 |
25 | /**
26 | * Find direct child custom elements that need loading, then load them
27 | */
28 | #loadLazy() {
29 | const elements =
30 | [...this.children].filter(_ => _.localName.includes('-'));
31 | const unregistered =
32 | elements.filter(_ => !customElements.get(_.localName));
33 | if (unregistered.length) {
34 | Suspense.waitFor(this,
35 | ...unregistered.map(_ => this.#loadElement(_))
36 | );
37 | }
38 | }
39 |
40 | /**
41 | * Load a custom element
42 | * @param {*} element
43 | * @returns {Promise} a promise that settles when loading completes or fails
44 | */
45 | #loadElement(element) {
46 | // does the element advertise its own path?
47 | let url = element.getAttribute('lazy-path');
48 | if (!url) {
49 | // strip leading x- off the name
50 | const cleanName = element.localName.replace(/^x-/, '').toLowerCase();
51 | // root directory to load from, relative to current document
52 | const rootDir = this.getAttribute('root') || './components/';
53 | // assume component is in its own folder
54 | url = `${rootDir}${cleanName}/${cleanName}.js`;
55 | }
56 | // dynamically import, then register if not yet registered
57 | return import(new URL(url, document.location)).then(module =>
58 | !customElements.get(element.localName) && module && module.default());
59 | }
60 | }
61 |
62 | export const registerLazy = () => customElements.define('x-lazy', Lazy);
63 |
--------------------------------------------------------------------------------
/public/blog/articles/2024-09-09-sweet-suspense/example/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | Lazy, Suspense and Error Boundary example
5 |
6 |
11 |
12 |
13 |
14 |
15 |
16 |
--------------------------------------------------------------------------------
/public/blog/articles/2024-09-09-sweet-suspense/example/index.js:
--------------------------------------------------------------------------------
1 | import { registerLazy } from './components/lazy.js';
2 | import { registerSuspense } from './components/suspense.js';
3 | import { registerErrorBoundary } from './components/error-boundary.js';
4 | import { registerErrorMessage } from './components/error-message.js';
5 |
6 | customElements.define('x-demo', class extends HTMLElement {
7 |
8 | constructor() {
9 | super();
10 | registerLazy();
11 | registerSuspense();
12 | registerErrorBoundary();
13 | registerErrorMessage();
14 | }
15 |
16 | connectedCallback() {
17 | this.innerHTML = `
18 | Lazy loading demo
19 | Load lazy
20 | Reset error
21 |
22 |
Click to load..
23 |
24 | `;
25 | const resetBtn = this.querySelector('button#error-reset')
26 | resetBtn.onclick = () => {
27 | this.querySelector('x-error-boundary').reset();
28 | resetBtn.setAttribute('disabled', true);
29 | };
30 | const loadBtn = this.querySelector('button#lazy-load');
31 | loadBtn.onclick = () => {
32 | this.querySelector('div#lazy-load-div').innerHTML = `
33 |
34 |
35 |
36 | Loading...
37 |
38 |
39 |
40 | `
41 | this.querySelector('x-error-boundary').addEventListener('error', _ => {
42 | resetBtn.removeAttribute('disabled');
43 | });
44 | loadBtn.setAttribute('disabled', true);
45 | };
46 | }
47 |
48 | });
49 |
--------------------------------------------------------------------------------
/public/blog/articles/2024-09-09-sweet-suspense/image.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/jsebrech/plainvanilla/48b88bbfc51210228cb7c5b8dd797a208c663bc5/public/blog/articles/2024-09-09-sweet-suspense/image.webp
--------------------------------------------------------------------------------
/public/blog/articles/2024-09-09-sweet-suspense/lazy1.js:
--------------------------------------------------------------------------------
1 | customElements.define('x-lazy', class extends HTMLElement {
2 | connectedCallback() {
3 | this.style.display = 'contents';
4 | this.#loadLazy();
5 | }
6 |
7 | #loadLazy() {
8 | const elements =
9 | [...this.children].filter(_ => _.localName.includes('-'));
10 | const unregistered =
11 | elements.filter(_ => !customElements.get(_.localName));
12 | unregistered.forEach(_ => this.#loadElement(_));
13 | }
14 |
15 | #loadElement(element) {
16 | // TODO: load the custom element
17 | }
18 | });
19 |
--------------------------------------------------------------------------------
/public/blog/articles/2024-09-09-sweet-suspense/lazy2-partial.js:
--------------------------------------------------------------------------------
1 | #loadElement(element) {
2 | // strip leading x- off the name
3 | const cleanName = element.localName.replace(/^x-/, '').toLowerCase();
4 | // assume component is in its own folder
5 | const url = `./components/${cleanName}/${cleanName}.js`;
6 | // dynamically import, then register if not yet registered
7 | return import(new URL(url, document.location)).then(module =>
8 | !customElements.get(element.localName) && module && module.default());
9 | }
--------------------------------------------------------------------------------
/public/blog/articles/2024-09-09-sweet-suspense/lazy3-partial.js:
--------------------------------------------------------------------------------
1 | #loadLazy() {
2 | const elements =
3 | [...this.children].filter(_ => _.localName.includes('-'));
4 | const unregistered =
5 | elements.filter(_ => !customElements.get(_.localName));
6 | if (unregistered.length) {
7 | Suspense.waitFor(this,
8 | ...unregistered.map(_ => this.#loadElement(_))
9 | );
10 | }
11 | }
--------------------------------------------------------------------------------
/public/blog/articles/2024-09-09-sweet-suspense/suspense1-partial.html:
--------------------------------------------------------------------------------
1 |
2 | Loading...
3 |
4 |
--------------------------------------------------------------------------------
/public/blog/articles/2024-09-09-sweet-suspense/suspense1.js:
--------------------------------------------------------------------------------
1 | export class Suspense extends HTMLElement {
2 | #fallbackSlot;
3 | #contentSlot;
4 |
5 | set loading(isLoading) {
6 | if (!this.#fallbackSlot) return;
7 | this.#fallbackSlot.style.display = isLoading ? 'contents' : 'none';
8 | this.#contentSlot.style.display = !isLoading ? 'contents' : 'none';
9 | }
10 |
11 | constructor() {
12 | super();
13 | this.attachShadow({ mode: 'open' });
14 |
15 | this.#fallbackSlot = document.createElement('slot');
16 | this.#fallbackSlot.style.display = 'none';
17 | this.#fallbackSlot.name = 'fallback';
18 | this.#contentSlot = document.createElement('slot');
19 | this.shadowRoot.append(this.#fallbackSlot, this.#contentSlot);
20 | }
21 |
22 | connectedCallback() {
23 | this.style.display = 'contents';
24 | }
25 | }
26 | customElements.define('x-suspense', Suspense);
--------------------------------------------------------------------------------
/public/blog/articles/2024-09-09-sweet-suspense/suspense2-partial.js:
--------------------------------------------------------------------------------
1 | static waitFor(sender, ...promises) {
2 | const suspense = sender.closest('x-suspense');
3 | if (suspense) suspense.addPromises(...promises);
4 | }
5 |
6 | addPromises(...promises) {
7 | if (!promises.length) return;
8 | this.loading = true;
9 | // combine into previous promises if there are any
10 | const newPromise = this.#waitingForPromise =
11 | Promise.allSettled([...promises, this.#waitingForPromise]);
12 | // wait for all promises to complete
13 | newPromise.then(_ => {
14 | // if no newer promises were added, we're done
15 | if (newPromise === this.#waitingForPromise) {
16 | this.loading = false;
17 | }
18 | });
19 | }
--------------------------------------------------------------------------------
/public/blog/articles/2024-09-16-life-and-times-of-a-custom-element/defined/example.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | defining the custom element
6 |
18 |
19 |
20 | Custom element:
21 | Define
22 | Reload
23 |
24 |
36 |
37 |
--------------------------------------------------------------------------------
/public/blog/articles/2024-09-16-life-and-times-of-a-custom-element/image.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/jsebrech/plainvanilla/48b88bbfc51210228cb7c5b8dd797a208c663bc5/public/blog/articles/2024-09-16-life-and-times-of-a-custom-element/image.webp
--------------------------------------------------------------------------------
/public/blog/articles/2024-09-16-life-and-times-of-a-custom-element/observer/example.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | custom element with observer
6 |
10 |
11 |
12 |
13 | Add one more
14 | Reload
15 |
16 |
39 |
40 |
--------------------------------------------------------------------------------
/public/blog/articles/2024-09-16-life-and-times-of-a-custom-element/shadowed/example.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | shadowed custom element
6 |
10 |
11 |
12 | <x-shadowed>: undefined, not shadowed
13 | <x-shadowed-later>: undefined, not shadowed
14 | Define
15 | Reload
16 |
17 |
47 |
48 |
--------------------------------------------------------------------------------
/public/blog/articles/2024-09-16-life-and-times-of-a-custom-element/undefined/example.css:
--------------------------------------------------------------------------------
1 | body { font-family: system-ui, sans-serif; margin: 1em; }
2 | button { user-select: none; }
3 | /* based on https://github.com/argyleink/gui-challenges/blob/main/tooltips/tool-tip.css */
4 | x-tooltip {
5 | --color: lightgray;
6 | --bg: hsl(0 0% 20%);
7 |
8 | pointer-events: none;
9 | user-select: none;
10 | /* animate in on hover or focus of parent element */
11 | z-index: 1;
12 | opacity: 0;
13 | transition: opacity .2s ease;
14 | transition-delay: 200ms;
15 | :is(:hover, :focus-visible, :active) > & {
16 | opacity: 1;
17 | }
18 | /* vertically center and move to the right */
19 | position:absolute;
20 | top:50%;
21 | transform:translateY(-50%);
22 | left: calc(100% + 15px);
23 | padding: 0.5em;
24 | inline-size: max-content;
25 | max-inline-size: 25ch;
26 | /* color, backdrop and shadow */
27 | color: var(--color);
28 | filter:
29 | drop-shadow(0 3px 3px hsl(0 0% 0% / 50%))
30 | drop-shadow(0 12px 12px hsl(0 0% 0% / 50%));
31 | &::after {
32 | content: "";
33 | background: var(--bg);
34 | position: absolute;
35 | z-index: -1;
36 | left: 0;
37 | top: 50%;
38 | transform:translateY(-50%);
39 | width: 100%;
40 | height: 100%;
41 | border-radius: 5px;
42 | }
43 | /* fix drop shadow in safari */
44 | will-change: filter;
45 | }
46 |
47 | button:has(> x-tooltip) {
48 | position: relative;
49 | }
--------------------------------------------------------------------------------
/public/blog/articles/2024-09-16-life-and-times-of-a-custom-element/undefined/example.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 | undefined custom element
7 |
8 |
9 |
10 | Hover me
11 | Thanks for hovering!
12 |
13 |
14 |
--------------------------------------------------------------------------------
/public/blog/articles/2024-09-28-unreasonable-effectiveness-of-vanilla-js/complete/AddTask.js:
--------------------------------------------------------------------------------
1 | customElements.define('task-add', class extends HTMLElement {
2 | connectedCallback() {
3 | this.innerHTML = `
4 |
5 | Add
6 | `;
7 | this.querySelector('button').onclick = () => {
8 | const input = this.querySelector('input');
9 | this.closest('tasks-context').dispatch({
10 | type: 'added',
11 | id: nextId++,
12 | text: input.value
13 | });
14 | input.value = '';
15 | };
16 | }
17 | })
18 |
19 | let nextId = 3;
20 |
--------------------------------------------------------------------------------
/public/blog/articles/2024-09-28-unreasonable-effectiveness-of-vanilla-js/complete/App.js:
--------------------------------------------------------------------------------
1 | customElements.define('tasks-app', class extends HTMLElement {
2 | connectedCallback() {
3 | this.innerHTML = `
4 |
5 | Day off in Kyoto
6 |
7 |
8 |
9 | `;
10 | }
11 | });
12 |
--------------------------------------------------------------------------------
/public/blog/articles/2024-09-28-unreasonable-effectiveness-of-vanilla-js/complete/TasksContext.js:
--------------------------------------------------------------------------------
1 | customElements.define('tasks-context', class extends HTMLElement {
2 | #tasks = structuredClone(initialTasks);
3 | get tasks() { return this.#tasks; }
4 | set tasks(tasks) {
5 | this.#tasks = tasks;
6 | this.dispatchEvent(new Event('change'));
7 | }
8 |
9 | dispatch(action) {
10 | this.tasks = tasksReducer(this.tasks, action);
11 | }
12 |
13 | connectedCallback() {
14 | this.style.display = 'contents';
15 | }
16 | });
17 |
18 | function tasksReducer(tasks, action) {
19 | switch (action.type) {
20 | case 'added': {
21 | return [...tasks, {
22 | id: action.id,
23 | text: action.text,
24 | done: false
25 | }];
26 | }
27 | case 'changed': {
28 | return tasks.map(t => {
29 | if (t.id === action.task.id) {
30 | return action.task;
31 | } else {
32 | return t;
33 | }
34 | });
35 | }
36 | case 'deleted': {
37 | return tasks.filter(t => t.id !== action.id);
38 | }
39 | default: {
40 | throw Error('Unknown action: ' + action.type);
41 | }
42 | }
43 | }
44 |
45 | const initialTasks = [
46 | { id: 0, text: 'Philosopher’s Path', done: true },
47 | { id: 1, text: 'Visit the temple', done: false },
48 | { id: 2, text: 'Drink matcha', done: false }
49 | ];
50 |
--------------------------------------------------------------------------------
/public/blog/articles/2024-09-28-unreasonable-effectiveness-of-vanilla-js/complete/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 | Document
7 |
8 |
9 |
10 |
11 |
12 |
13 |
--------------------------------------------------------------------------------
/public/blog/articles/2024-09-28-unreasonable-effectiveness-of-vanilla-js/complete/index.js:
--------------------------------------------------------------------------------
1 | import './App.js';
2 | import './AddTask.js';
3 | import './TaskList.js';
4 | import './TasksContext.js';
5 |
6 | const render = () => {
7 | const root = document.getElementById('root');
8 | root.append(document.createElement('tasks-app'));
9 | }
10 |
11 | document.addEventListener('DOMContentLoaded', render);
12 |
--------------------------------------------------------------------------------
/public/blog/articles/2024-09-28-unreasonable-effectiveness-of-vanilla-js/complete/styles.css:
--------------------------------------------------------------------------------
1 | * {
2 | box-sizing: border-box;
3 | }
4 |
5 | body {
6 | font-family: sans-serif;
7 | margin: 20px;
8 | padding: 0;
9 | }
10 |
11 | h1 {
12 | margin-top: 0;
13 | font-size: 22px;
14 | }
15 |
16 | h2 {
17 | margin-top: 0;
18 | font-size: 20px;
19 | }
20 |
21 | h3 {
22 | margin-top: 0;
23 | font-size: 18px;
24 | }
25 |
26 | h4 {
27 | margin-top: 0;
28 | font-size: 16px;
29 | }
30 |
31 | h5 {
32 | margin-top: 0;
33 | font-size: 14px;
34 | }
35 |
36 | h6 {
37 | margin-top: 0;
38 | font-size: 12px;
39 | }
40 |
41 | code {
42 | font-size: 1.2em;
43 | }
44 |
45 | ul {
46 | padding-inline-start: 20px;
47 | }
48 |
49 | button { margin: 5px; }
50 | li { list-style-type: none; }
51 | ul, li { margin: 0; padding: 0; }
52 |
--------------------------------------------------------------------------------
/public/blog/articles/2024-09-28-unreasonable-effectiveness-of-vanilla-js/image.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/jsebrech/plainvanilla/48b88bbfc51210228cb7c5b8dd797a208c663bc5/public/blog/articles/2024-09-28-unreasonable-effectiveness-of-vanilla-js/image.webp
--------------------------------------------------------------------------------
/public/blog/articles/2024-09-28-unreasonable-effectiveness-of-vanilla-js/react/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "react.dev",
3 | "version": "0.0.0",
4 | "main": "/src/index.js",
5 | "scripts": {
6 | "start": "react-scripts start",
7 | "build": "react-scripts build",
8 | "test": "react-scripts test --env=jsdom",
9 | "eject": "react-scripts eject"
10 | },
11 | "dependencies": {
12 | "react": "^18.0.0",
13 | "react-dom": "^18.0.0",
14 | "react-scripts": "^5.0.0"
15 | },
16 | "devDependencies": {}
17 | }
--------------------------------------------------------------------------------
/public/blog/articles/2024-09-28-unreasonable-effectiveness-of-vanilla-js/react/public/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 | Document
7 |
8 |
9 |
10 |
11 |
--------------------------------------------------------------------------------
/public/blog/articles/2024-09-28-unreasonable-effectiveness-of-vanilla-js/react/src/AddTask.js:
--------------------------------------------------------------------------------
1 | import { useState } from 'react';
2 | import { useTasksDispatch } from './TasksContext.js';
3 |
4 | export default function AddTask() {
5 | const [text, setText] = useState('');
6 | const dispatch = useTasksDispatch();
7 | return (
8 | <>
9 | setText(e.target.value)}
13 | />
14 | {
15 | setText('');
16 | dispatch({
17 | type: 'added',
18 | id: nextId++,
19 | text: text,
20 | });
21 | }}>Add
22 | >
23 | );
24 | }
25 |
26 | let nextId = 3;
27 |
--------------------------------------------------------------------------------
/public/blog/articles/2024-09-28-unreasonable-effectiveness-of-vanilla-js/react/src/App.js:
--------------------------------------------------------------------------------
1 | import AddTask from './AddTask.js';
2 | import TaskList from './TaskList.js';
3 | import { TasksProvider } from './TasksContext.js';
4 |
5 | export default function TaskApp() {
6 | return (
7 |
8 | Day off in Kyoto
9 |
10 |
11 |
12 | );
13 | }
14 |
--------------------------------------------------------------------------------
/public/blog/articles/2024-09-28-unreasonable-effectiveness-of-vanilla-js/react/src/TaskList.js:
--------------------------------------------------------------------------------
1 | import { useState } from 'react';
2 | import { useTasks, useTasksDispatch } from './TasksContext.js';
3 |
4 | export default function TaskList() {
5 | const tasks = useTasks();
6 | return (
7 |
8 | {tasks.map(task => (
9 |
10 |
11 |
12 | ))}
13 |
14 | );
15 | }
16 |
17 | function Task({ task }) {
18 | const [isEditing, setIsEditing] = useState(false);
19 | const dispatch = useTasksDispatch();
20 | let taskContent;
21 | if (isEditing) {
22 | taskContent = (
23 | <>
24 | {
27 | dispatch({
28 | type: 'changed',
29 | task: {
30 | ...task,
31 | text: e.target.value
32 | }
33 | });
34 | }} />
35 | setIsEditing(false)}>
36 | Save
37 |
38 | >
39 | );
40 | } else {
41 | taskContent = (
42 | <>
43 | {task.text}
44 | setIsEditing(true)}>
45 | Edit
46 |
47 | >
48 | );
49 | }
50 | return (
51 |
52 | {
56 | dispatch({
57 | type: 'changed',
58 | task: {
59 | ...task,
60 | done: e.target.checked
61 | }
62 | });
63 | }}
64 | />
65 | {taskContent}
66 | {
67 | dispatch({
68 | type: 'deleted',
69 | id: task.id
70 | });
71 | }}>
72 | Delete
73 |
74 |
75 | );
76 | }
77 |
--------------------------------------------------------------------------------
/public/blog/articles/2024-09-28-unreasonable-effectiveness-of-vanilla-js/react/src/TasksContext.js:
--------------------------------------------------------------------------------
1 | import { createContext, useContext, useReducer } from 'react';
2 |
3 | const TasksContext = createContext(null);
4 |
5 | const TasksDispatchContext = createContext(null);
6 |
7 | export function TasksProvider({ children }) {
8 | const [tasks, dispatch] = useReducer(
9 | tasksReducer,
10 | initialTasks
11 | );
12 |
13 | return (
14 |
15 |
16 | {children}
17 |
18 |
19 | );
20 | }
21 |
22 | export function useTasks() {
23 | return useContext(TasksContext);
24 | }
25 |
26 | export function useTasksDispatch() {
27 | return useContext(TasksDispatchContext);
28 | }
29 |
30 | function tasksReducer(tasks, action) {
31 | switch (action.type) {
32 | case 'added': {
33 | return [...tasks, {
34 | id: action.id,
35 | text: action.text,
36 | done: false
37 | }];
38 | }
39 | case 'changed': {
40 | return tasks.map(t => {
41 | if (t.id === action.task.id) {
42 | return action.task;
43 | } else {
44 | return t;
45 | }
46 | });
47 | }
48 | case 'deleted': {
49 | return tasks.filter(t => t.id !== action.id);
50 | }
51 | default: {
52 | throw Error('Unknown action: ' + action.type);
53 | }
54 | }
55 | }
56 |
57 | const initialTasks = [
58 | { id: 0, text: 'Philosopher’s Path', done: true },
59 | { id: 1, text: 'Visit the temple', done: false },
60 | { id: 2, text: 'Drink matcha', done: false }
61 | ];
62 |
--------------------------------------------------------------------------------
/public/blog/articles/2024-09-28-unreasonable-effectiveness-of-vanilla-js/react/src/index.js:
--------------------------------------------------------------------------------
1 | import React, { StrictMode } from "react";
2 | import { createRoot } from "react-dom/client";
3 | import "./styles.css";
4 |
5 | import App from "./App";
6 |
7 | const root = createRoot(document.getElementById("root"));
8 | root.render(
9 |
10 |
11 |
12 | );
--------------------------------------------------------------------------------
/public/blog/articles/2024-09-28-unreasonable-effectiveness-of-vanilla-js/react/src/styles.css:
--------------------------------------------------------------------------------
1 | * {
2 | box-sizing: border-box;
3 | }
4 |
5 | body {
6 | font-family: sans-serif;
7 | margin: 20px;
8 | padding: 0;
9 | }
10 |
11 | h1 {
12 | margin-top: 0;
13 | font-size: 22px;
14 | }
15 |
16 | h2 {
17 | margin-top: 0;
18 | font-size: 20px;
19 | }
20 |
21 | h3 {
22 | margin-top: 0;
23 | font-size: 18px;
24 | }
25 |
26 | h4 {
27 | margin-top: 0;
28 | font-size: 16px;
29 | }
30 |
31 | h5 {
32 | margin-top: 0;
33 | font-size: 14px;
34 | }
35 |
36 | h6 {
37 | margin-top: 0;
38 | font-size: 12px;
39 | }
40 |
41 | code {
42 | font-size: 1.2em;
43 | }
44 |
45 | ul {
46 | padding-inline-start: 20px;
47 | }
48 |
49 | button { margin: 5px; }
50 | li { list-style-type: none; }
51 | ul, li { margin: 0; padding: 0; }
52 |
--------------------------------------------------------------------------------
/public/blog/articles/2024-09-30-lived-experience/image.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/jsebrech/plainvanilla/48b88bbfc51210228cb7c5b8dd797a208c663bc5/public/blog/articles/2024-09-30-lived-experience/image.webp
--------------------------------------------------------------------------------
/public/blog/articles/2024-10-07-needs-more-context/combined/context-provider.js:
--------------------------------------------------------------------------------
1 | export class ContextProvider extends EventTarget {
2 | #value;
3 | get value() { return this.#value }
4 | set value(v) { this.#value = v; this.dispatchEvent(new Event('change')); }
5 |
6 | #context;
7 | get context() { return this.#context }
8 |
9 | constructor(target, context, initialValue = undefined) {
10 | super();
11 | this.#context = context;
12 | this.#value = initialValue;
13 | this.handle = this.handle.bind(this);
14 | if (target) this.attach(target);
15 | }
16 |
17 | attach(target) {
18 | target.addEventListener('context-request', this.handle);
19 | }
20 |
21 | detach(target) {
22 | target.removeEventListener('context-request', this.handle);
23 | }
24 |
25 | /**
26 | * Handle a context-request event
27 | * @param {ContextRequestEvent} e
28 | */
29 | handle(e) {
30 | if (e.context === this.context) {
31 | if (e.subscribe) {
32 | const unsubscribe = () => this.removeEventListener('change', update);
33 | const update = () => e.callback(this.value, unsubscribe);
34 | this.addEventListener('change', update);
35 | update();
36 | } else {
37 | e.callback(this.value);
38 | }
39 | e.stopPropagation();
40 | }
41 | }
42 | }
43 |
--------------------------------------------------------------------------------
/public/blog/articles/2024-10-07-needs-more-context/combined/context-request.js:
--------------------------------------------------------------------------------
1 | export class ContextRequestEvent extends Event {
2 | constructor(context, callback, subscribe) {
3 | super('context-request', {
4 | bubbles: true,
5 | composed: true,
6 | });
7 | this.context = context;
8 | this.callback = callback;
9 | this.subscribe = subscribe;
10 | }
11 | }
12 |
--------------------------------------------------------------------------------
/public/blog/articles/2024-10-07-needs-more-context/combined/index.css:
--------------------------------------------------------------------------------
1 | body {
2 | margin: 1em;
3 | font-family: system-ui, sans-serif;
4 | }
5 |
6 | theme-panel {
7 | display: block;
8 | border: 1px dotted gray;
9 | min-height: 2em;
10 | max-width: 400px;
11 | padding: 1em;
12 | margin-bottom: 1em;
13 | }
14 |
15 | .panel-light {
16 | color: #222;
17 | background: #fff;
18 | }
19 |
20 | .panel-dark {
21 | color: #fff;
22 | background: rgb(23, 32, 42);
23 | }
24 |
25 | theme-toggle button {
26 | margin: 0;
27 | padding: 5px;
28 | }
29 |
30 | .button-light,
31 | .button-dark {
32 | border: 1px solid #777;
33 | }
34 |
35 | .button-dark {
36 | background: #222;
37 | color: #fff;
38 | }
39 |
40 | .button-light {
41 | background: #fff;
42 | color: #222;
43 | }
--------------------------------------------------------------------------------
/public/blog/articles/2024-10-07-needs-more-context/combined/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | tiny-context example
6 |
7 |
8 |
9 |
10 |
11 |
12 |
--------------------------------------------------------------------------------
/public/blog/articles/2024-10-07-needs-more-context/combined/index.js:
--------------------------------------------------------------------------------
1 | import { ContextRequestEvent } from "./context-request.js";
2 | import "./theme-provider.js"; // global provider on body
3 | import "./theme-context.js"; // element with local provider
4 |
5 | customElements.define('theme-demo', class extends HTMLElement {
6 | connectedCallback() {
7 | this.innerHTML = `
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 | Reparent toggle
16 | `;
17 | this.querySelector('button').onclick = reparent;
18 | }
19 | });
20 |
21 | customElements.define('theme-panel', class extends HTMLElement {
22 | #unsubscribe;
23 |
24 | connectedCallback() {
25 | this.dispatchEvent(new ContextRequestEvent('theme', (theme, unsubscribe) => {
26 | this.className = 'panel-' + theme;
27 | this.#unsubscribe = unsubscribe;
28 | }, true));
29 | }
30 |
31 | disconnectedCallback() {
32 | this.#unsubscribe?.();
33 | }
34 | });
35 |
36 | customElements.define('theme-toggle', class extends HTMLElement {
37 | #unsubscribe;
38 |
39 | connectedCallback() {
40 | this.innerHTML = 'Toggle ';
41 | this.dispatchEvent(new ContextRequestEvent('theme-toggle', (toggle) => {
42 | this.querySelector('button').onclick = toggle;
43 | }));
44 | this.dispatchEvent(new ContextRequestEvent('theme', (theme, unsubscribe) => {
45 | this.querySelector('button').className = 'button-' + theme;
46 | this.#unsubscribe = unsubscribe;
47 | }, true));
48 | }
49 |
50 | disconnectedCallback() {
51 | this.#unsubscribe?.();
52 | }
53 | });
54 |
55 | function reparent() {
56 | const toggle = document.querySelector('theme-toggle');
57 | const first = document.querySelector('theme-panel#first');
58 | const second = document.querySelector('theme-panel#second');
59 | if (toggle.parentNode === second) {
60 | first.append(toggle);
61 | } else {
62 | second.append(toggle);
63 | }
64 | }
--------------------------------------------------------------------------------
/public/blog/articles/2024-10-07-needs-more-context/combined/theme-context.js:
--------------------------------------------------------------------------------
1 | import { ContextProvider } from "./context-provider.js";
2 |
3 | customElements.define('theme-context', class extends HTMLElement {
4 | themeProvider = new ContextProvider(this, 'theme', 'light');
5 | toggleProvider = new ContextProvider(this, 'theme-toggle', () => {
6 | this.themeProvider.value = this.themeProvider.value === 'light' ? 'dark' : 'light';
7 | });
8 | connectedCallback() {
9 | this.style.display = 'contents';
10 | }
11 | });
12 |
--------------------------------------------------------------------------------
/public/blog/articles/2024-10-07-needs-more-context/combined/theme-provider.js:
--------------------------------------------------------------------------------
1 | // loaded with
2 |
3 | import { ContextProvider } from "./context-provider.js";
4 |
5 | const themeProvider = new ContextProvider(document.body, 'theme', 'light');
6 | const toggleProvider = new ContextProvider(document.body, 'theme-toggle', () => {
7 | themeProvider.value = themeProvider.value === 'light' ? 'dark' : 'light';
8 | });
9 |
--------------------------------------------------------------------------------
/public/blog/articles/2024-10-07-needs-more-context/context-provider.js:
--------------------------------------------------------------------------------
1 | export class ContextProvider extends EventTarget {
2 | #value;
3 | get value() { return this.#value }
4 | set value(v) { this.#value = v; this.dispatchEvent(new Event('change')); }
5 |
6 | #context;
7 | get context() { return this.#context }
8 |
9 | constructor(target, context, initialValue = undefined) {
10 | super();
11 | this.#context = context;
12 | this.#value = initialValue;
13 | this.handle = this.handle.bind(this);
14 | if (target) this.attach(target);
15 | }
16 |
17 | attach(target) {
18 | target.addEventListener('context-request', this.handle);
19 | }
20 |
21 | detach(target) {
22 | target.removeEventListener('context-request', this.handle);
23 | }
24 |
25 | /**
26 | * Handle a context-request event
27 | * @param {ContextRequestEvent} e
28 | */
29 | handle(e) {
30 | if (e.context === this.context) {
31 | if (e.subscribe) {
32 | const unsubscribe = () => this.removeEventListener('change', update);
33 | const update = () => e.callback(this.value, unsubscribe);
34 | this.addEventListener('change', update);
35 | update();
36 | } else {
37 | e.callback(this.value);
38 | }
39 | e.stopPropagation();
40 | }
41 | }
42 | }
43 |
--------------------------------------------------------------------------------
/public/blog/articles/2024-10-07-needs-more-context/context-request-1.js:
--------------------------------------------------------------------------------
1 | class ContextRequestEvent extends Event {
2 | constructor(context, callback, subscribe) {
3 | super('context-request', {
4 | bubbles: true,
5 | composed: true,
6 | });
7 | this.context = context;
8 | this.callback = callback;
9 | this.subscribe = subscribe;
10 | }
11 | }
12 |
13 | customElements.define('my-component', class extends HTMLElement {
14 | connectedCallback() {
15 | this.dispatchEvent(
16 | new ContextRequestEvent('theme', (theme) => {
17 | // ...
18 | })
19 | );
20 | }
21 | });
22 |
--------------------------------------------------------------------------------
/public/blog/articles/2024-10-07-needs-more-context/context-request-2.js:
--------------------------------------------------------------------------------
1 | customElements.define('my-component', class extends HTMLElement {
2 | connectedCallback() {
3 | let theme = 'light'; // default value
4 | this.dispatchEvent(
5 | new ContextRequestEvent('theme', t => theme = t)
6 | );
7 | // do something with theme
8 | }
9 | });
10 |
--------------------------------------------------------------------------------
/public/blog/articles/2024-10-07-needs-more-context/context-request-3.js:
--------------------------------------------------------------------------------
1 | customElements.define('my-component', class extends HTMLElement {
2 | #unsubscribe;
3 | connectedCallback() {
4 | this.dispatchEvent(
5 | new ContextRequestEvent('theme', (theme, unsubscribe) => {
6 | this.#unsubscribe = unsubscribe;
7 | // do something with theme
8 | }, true)
9 | );
10 | }
11 | disconnectedCallback() {
12 | this.#unsubscribe?.();
13 | }
14 | });
15 |
--------------------------------------------------------------------------------
/public/blog/articles/2024-10-07-needs-more-context/context-request-4.js:
--------------------------------------------------------------------------------
1 | customElements.define('my-component', class extends HTMLElement {
2 | connectedCallback() {
3 | let theme = 'light';
4 | this.dispatchEvent(
5 | new ContextRequestEvent('theme', (t, unsubscribe) => {
6 | theme = t;
7 | unsubscribe?.();
8 | })
9 | );
10 | // do something with theme
11 | }
12 | });
13 |
--------------------------------------------------------------------------------
/public/blog/articles/2024-10-07-needs-more-context/image.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/jsebrech/plainvanilla/48b88bbfc51210228cb7c5b8dd797a208c663bc5/public/blog/articles/2024-10-07-needs-more-context/image.webp
--------------------------------------------------------------------------------
/public/blog/articles/2024-10-07-needs-more-context/theme-context-fragment.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
--------------------------------------------------------------------------------
/public/blog/articles/2024-10-07-needs-more-context/theme-context.js:
--------------------------------------------------------------------------------
1 | customElements.define('theme-context', class extends HTMLElement {
2 | themeProvider = new ContextProvider(this, 'theme', 'light');
3 | toggleProvider = new ContextProvider(this, 'theme-toggle', () => {
4 | this.themeProvider.value = this.themeProvider.value === 'light' ? 'dark' : 'light';
5 | });
6 | connectedCallback() {
7 | this.style.display = 'contents';
8 | }
9 | });
10 |
--------------------------------------------------------------------------------
/public/blog/articles/2024-10-07-needs-more-context/theme-provider.js:
--------------------------------------------------------------------------------
1 | // loaded with
2 |
3 | import { ContextProvider } from "./context-provider.js";
4 |
5 | const themeProvider = new ContextProvider(document.body, 'theme', 'light');
6 | const toggleProvider = new ContextProvider(document.body, 'theme-toggle', () => {
7 | themeProvider.value = themeProvider.value === 'light' ? 'dark' : 'light';
8 | });
9 |
--------------------------------------------------------------------------------
/public/blog/articles/2024-10-20-editing-plain-vanilla/.hintrc:
--------------------------------------------------------------------------------
1 | {
2 | "extends": [
3 | "development"
4 | ],
5 | "hints": {
6 | "compat-api/html": [
7 | "default",
8 | {
9 | "ignore": [
10 | "iframe[loading]"
11 | ]
12 | }
13 | ],
14 | "no-inline-styles": "off"
15 | }
16 | }
--------------------------------------------------------------------------------
/public/blog/articles/2024-10-20-editing-plain-vanilla/devdocs.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/jsebrech/plainvanilla/48b88bbfc51210228cb7c5b8dd797a208c663bc5/public/blog/articles/2024-10-20-editing-plain-vanilla/devdocs.webp
--------------------------------------------------------------------------------
/public/blog/articles/2024-10-20-editing-plain-vanilla/eslint.config.cjs:
--------------------------------------------------------------------------------
1 | /* eslint-disable no-undef */
2 | const globals = require("globals");
3 | const js = require("@eslint/js");
4 |
5 | module.exports = [
6 | js.configs.recommended,
7 | {
8 | languageOptions: {
9 | globals: {
10 | ...globals.browser,
11 | ...globals.mocha
12 | },
13 | ecmaVersion: 2022,
14 | sourceType: "module",
15 | }
16 | },
17 | {
18 | ignores: [
19 | "public/blog/articles/",
20 | "**/lib/",
21 | "**/react/",
22 | ]
23 | }
24 | ];
--------------------------------------------------------------------------------
/public/blog/articles/2024-10-20-editing-plain-vanilla/eslinterror.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/jsebrech/plainvanilla/48b88bbfc51210228cb7c5b8dd797a208c663bc5/public/blog/articles/2024-10-20-editing-plain-vanilla/eslinterror.png
--------------------------------------------------------------------------------
/public/blog/articles/2024-10-20-editing-plain-vanilla/image.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/jsebrech/plainvanilla/48b88bbfc51210228cb7c5b8dd797a208c663bc5/public/blog/articles/2024-10-20-editing-plain-vanilla/image.webp
--------------------------------------------------------------------------------
/public/blog/articles/2024-10-20-editing-plain-vanilla/live-preview.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/jsebrech/plainvanilla/48b88bbfc51210228cb7c5b8dd797a208c663bc5/public/blog/articles/2024-10-20-editing-plain-vanilla/live-preview.webp
--------------------------------------------------------------------------------
/public/blog/articles/2024-10-20-editing-plain-vanilla/syntax-highlighting.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/jsebrech/plainvanilla/48b88bbfc51210228cb7c5b8dd797a208c663bc5/public/blog/articles/2024-10-20-editing-plain-vanilla/syntax-highlighting.webp
--------------------------------------------------------------------------------
/public/blog/articles/2024-10-20-editing-plain-vanilla/webhinterror.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/jsebrech/plainvanilla/48b88bbfc51210228cb7c5b8dd797a208c663bc5/public/blog/articles/2024-10-20-editing-plain-vanilla/webhinterror.png
--------------------------------------------------------------------------------
/public/blog/articles/2024-12-16-caching-vanilla-sites/image.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/jsebrech/plainvanilla/48b88bbfc51210228cb7c5b8dd797a208c663bc5/public/blog/articles/2024-12-16-caching-vanilla-sites/image.webp
--------------------------------------------------------------------------------
/public/blog/articles/2024-12-16-caching-vanilla-sites/plainvanilla.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/jsebrech/plainvanilla/48b88bbfc51210228cb7c5b8dd797a208c663bc5/public/blog/articles/2024-12-16-caching-vanilla-sites/plainvanilla.webp
--------------------------------------------------------------------------------
/public/blog/articles/2024-12-16-caching-vanilla-sites/sw.js:
--------------------------------------------------------------------------------
1 | let cacheName = 'cache-worker-v1';
2 | // these are automatically cached when the site is first loaded
3 | let initialAssets = [
4 | './',
5 | 'index.html',
6 | 'index.js',
7 | 'index.css',
8 | 'manifest.json',
9 | 'android-chrome-512x512.png',
10 | 'favicon.ico',
11 | 'apple-touch-icon.png',
12 | 'styles/reset.css',
13 | // the rest will be auto-discovered
14 | ];
15 |
16 | // initial bundle (on first load)
17 | self.addEventListener('install', (event) => {
18 | event.waitUntil(
19 | caches.open(cacheName).then((cache) => {
20 | return cache.addAll(initialAssets);
21 | })
22 | );
23 | });
24 |
25 | // clear out stale caches after service worker update
26 | self.addEventListener('activate', (event) => {
27 | event.waitUntil(
28 | caches.keys().then((cacheNames) => {
29 | return Promise.all(
30 | cacheNames.map((cacheName) => {
31 | if (cacheName !== self.cacheName) {
32 | return caches.delete(cacheName);
33 | }
34 | })
35 | );
36 | })
37 | );
38 | });
39 |
40 | // default to fetching from cache, fallback to network
41 | self.addEventListener('fetch', (event) => {
42 | const url = new URL(event.request.url);
43 |
44 | // other origins bypass the cache
45 | if (url.origin !== location.origin) {
46 | networkOnly(event);
47 | // default to fetching from cache, and updating asynchronously
48 | } else {
49 | staleWhileRevalidate(event);
50 | }
51 | });
52 |
53 | const networkOnly = (event) => {
54 | event.respondWith(fetch(event.request));
55 | }
56 |
57 | // fetch events are serviced from cache if possible, but also updated behind the scenes
58 | const staleWhileRevalidate = (event) => {
59 | event.respondWith(
60 | caches.match(event.request).then(cachedResponse => {
61 | const networkUpdate =
62 | fetch(event.request).then(networkResponse => {
63 | caches.open(cacheName).then(
64 | cache => cache.put(event.request, networkResponse));
65 | return networkResponse.clone();
66 | }).catch(_ => /*ignore because we're probably offline*/_);
67 | return cachedResponse || networkUpdate;
68 | })
69 | );
70 | }
--------------------------------------------------------------------------------
/public/blog/articles/2024-12-16-caching-vanilla-sites/vercel.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/jsebrech/plainvanilla/48b88bbfc51210228cb7c5b8dd797a208c663bc5/public/blog/articles/2024-12-16-caching-vanilla-sites/vercel.webp
--------------------------------------------------------------------------------
/public/blog/articles/2025-01-01-new-years-resolve/example-index.js:
--------------------------------------------------------------------------------
1 | import { registerAvatarComponent } from './components/avatar.js';
2 | const app = () => {
3 | registerAvatarComponent();
4 | }
5 | document.addEventListener('DOMContentLoaded', app);
6 |
--------------------------------------------------------------------------------
/public/blog/articles/2025-01-01-new-years-resolve/http1.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/jsebrech/plainvanilla/48b88bbfc51210228cb7c5b8dd797a208c663bc5/public/blog/articles/2025-01-01-new-years-resolve/http1.png
--------------------------------------------------------------------------------
/public/blog/articles/2025-01-01-new-years-resolve/http2.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/jsebrech/plainvanilla/48b88bbfc51210228cb7c5b8dd797a208c663bc5/public/blog/articles/2025-01-01-new-years-resolve/http2.png
--------------------------------------------------------------------------------
/public/blog/articles/2025-01-01-new-years-resolve/image.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/jsebrech/plainvanilla/48b88bbfc51210228cb7c5b8dd797a208c663bc5/public/blog/articles/2025-01-01-new-years-resolve/image.webp
--------------------------------------------------------------------------------
/public/blog/articles/2025-01-01-new-years-resolve/layout.js:
--------------------------------------------------------------------------------
1 | class Layout extends HTMLElement {
2 | constructor() {
3 | super();
4 | this.attachShadow({ mode: 'open' });
5 | this.shadowRoot.innerHTML = `
6 |
7 |
8 | `;
9 | }
10 | }
11 |
12 | export const registerLayoutComponent =
13 | () => customElements.define('x-layout', Layout);
14 |
--------------------------------------------------------------------------------
/public/blog/articles/2025-01-01-new-years-resolve/layout.tsx:
--------------------------------------------------------------------------------
1 | import styles from './styles.module.css'
2 |
3 | export default function Layout({
4 | children,
5 | }: {
6 | children: React.ReactNode
7 | }) {
8 | return
9 | }
--------------------------------------------------------------------------------
/public/blog/articles/2025-04-21-attribute-property-duality/demo1.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 | demo 1
7 |
8 |
9 |
10 | Markup:
11 | Outputs:
12 |
17 |
18 |
19 |
--------------------------------------------------------------------------------
/public/blog/articles/2025-04-21-attribute-property-duality/demo1.js:
--------------------------------------------------------------------------------
1 | customElements.define('my-hello', class extends HTMLElement {
2 | connectedCallback() {
3 | this.textContent = `Hello, ${ this.value || 'null' }!`;
4 | }
5 | });
6 |
--------------------------------------------------------------------------------
/public/blog/articles/2025-04-21-attribute-property-duality/demo2.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 | demo 2
7 |
8 |
9 |
10 | Markup:
11 | Outputs:
12 |
17 |
18 |
19 |
--------------------------------------------------------------------------------
/public/blog/articles/2025-04-21-attribute-property-duality/demo2.js:
--------------------------------------------------------------------------------
1 | customElements.define('my-hello', class extends HTMLElement {
2 | connectedCallback() {
3 | this.textContent = `Hello, ${ this.getAttribute('value') || 'null' }!`;
4 | }
5 | });
6 |
--------------------------------------------------------------------------------
/public/blog/articles/2025-04-21-attribute-property-duality/demo3.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 | demo 3
7 |
8 |
9 |
10 | Markup:
11 | Outputs:
12 |
13 | myHello.value = 42
14 | Reload
15 |
16 |
24 |
25 |
26 |
--------------------------------------------------------------------------------
/public/blog/articles/2025-04-21-attribute-property-duality/demo3.js:
--------------------------------------------------------------------------------
1 | customElements.define('my-hello', class extends HTMLElement {
2 | get value() {
3 | return this.getAttribute('value');
4 | }
5 | set value(v) {
6 | this.setAttribute('value', String(v));
7 | }
8 |
9 | static observedAttributes = ['value'];
10 | attributeChangedCallback() {
11 | this.textContent = `Hello, ${ this.value || 'null' }!`;
12 | }
13 | });
14 |
--------------------------------------------------------------------------------
/public/blog/articles/2025-04-21-attribute-property-duality/demo4.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 | demo 4
7 |
8 |
9 |
10 | Markup:
11 | Outputs:
12 |
13 | toggle myHello.glam
14 |
15 |
23 |
24 |
25 |
--------------------------------------------------------------------------------
/public/blog/articles/2025-04-21-attribute-property-duality/demo4.js:
--------------------------------------------------------------------------------
1 | customElements.define('my-hello', class extends HTMLElement {
2 | get value() {
3 | return this.getAttribute('value');
4 | }
5 | set value(v) {
6 | this.setAttribute('value', String(v));
7 | }
8 |
9 | get glam() {
10 | return this.hasAttribute('glam');
11 | }
12 | set glam(v) {
13 | if (v) {
14 | this.setAttribute('glam', 'true');
15 | } else {
16 | this.removeAttribute('glam');
17 | }
18 | }
19 |
20 | static observedAttributes = ['value', 'glam'];
21 | attributeChangedCallback() {
22 | this.textContent =
23 | `Hello, ${ this.value || 'null' }!` +
24 | (this.glam ? '!!@#!' : '');
25 | }
26 | });
27 |
--------------------------------------------------------------------------------
/public/blog/articles/2025-04-21-attribute-property-duality/demo5-before.js:
--------------------------------------------------------------------------------
1 | // html:
2 |
3 | // js:
4 | const myHello = document.querySelector('my-hello');
5 | myHello.value = 42; // setter not called before define
6 | customElements.define('my-hello', /* ... */);
7 | console.log(myHello.getAttribute('value')); // -> "world"
8 |
--------------------------------------------------------------------------------
/public/blog/articles/2025-04-21-attribute-property-duality/demo5.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 | demo 5
7 |
8 |
9 |
10 | Markup:
11 | Outputs:
12 |
13 | toggle myHello.glam
14 |
15 |
23 |
24 |
25 |
--------------------------------------------------------------------------------
/public/blog/articles/2025-04-21-attribute-property-duality/demo5.js:
--------------------------------------------------------------------------------
1 | customElements.define('my-hello', class extends HTMLElement {
2 | get value() {
3 | return this.getAttribute('value');
4 | }
5 | set value(v) {
6 | this.setAttribute('value', String(v));
7 | }
8 |
9 | get glam() {
10 | return this.hasAttribute('glam');
11 | }
12 | set glam(v) {
13 | if (v) {
14 | this.setAttribute('glam', 'true');
15 | } else {
16 | this.removeAttribute('glam');
17 | }
18 | }
19 |
20 | static observedAttributes = ['value', 'glam'];
21 | attributeChangedCallback() {
22 | this.textContent =
23 | `Hello, ${ this.value || 'null' }!` +
24 | (this.glam ? '!!@#!' : '');
25 | }
26 |
27 | connectedCallback() {
28 | this.#upgradeProperty('value');
29 | this.#upgradeProperty('glam');
30 | }
31 |
32 | #upgradeProperty(prop) {
33 | if (this.hasOwnProperty(prop)) {
34 | let value = this[prop];
35 | delete this[prop];
36 | this[prop] = value;
37 | }
38 | }
39 | });
40 |
--------------------------------------------------------------------------------
/public/blog/articles/2025-04-21-attribute-property-duality/image.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/jsebrech/plainvanilla/48b88bbfc51210228cb7c5b8dd797a208c663bc5/public/blog/articles/2025-04-21-attribute-property-duality/image.webp
--------------------------------------------------------------------------------
/public/blog/articles/2025-05-09-form-control/demo1/index-partial.txt:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/public/blog/articles/2025-05-09-form-control/demo1/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 | demo 1
7 |
8 |
16 |
17 |
18 |
26 |
27 |
28 |
29 |
38 |
39 |
40 |
--------------------------------------------------------------------------------
/public/blog/articles/2025-05-09-form-control/demo1/input-inline.js:
--------------------------------------------------------------------------------
1 | customElements.define('input-inline', class extends HTMLElement {
2 |
3 | get value() {
4 | return this.getAttribute('value') ?? '';
5 | }
6 | set value(value) {
7 | this.setAttribute('value', String(value));
8 | }
9 |
10 | get name() {
11 | return this.getAttribute('name') ?? '';
12 | }
13 | set name(v) {
14 | this.setAttribute('name', String(v));
15 | }
16 |
17 | connectedCallback() {
18 | this.#update();
19 | }
20 |
21 | static observedAttributes = ['value'];
22 | attributeChangedCallback() {
23 | this.#update();
24 | }
25 |
26 | #update() {
27 | this.style.display = 'inline';
28 | if (this.textContent !== this.value) {
29 | this.textContent = this.value;
30 | }
31 | this.contentEditable = true;
32 | }
33 | });
--------------------------------------------------------------------------------
/public/blog/articles/2025-05-09-form-control/demo2/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 | demo 2
7 |
8 |
16 |
17 |
18 |
26 |
27 |
28 |
29 |
38 |
39 |
40 |
--------------------------------------------------------------------------------
/public/blog/articles/2025-05-09-form-control/demo2/input-inline-partial.js:
--------------------------------------------------------------------------------
1 | customElements.define('input-inline', class extends HTMLElement {
2 |
3 | #internals;
4 |
5 | /* ... */
6 |
7 | constructor() {
8 | super();
9 | this.#internals = this.attachInternals();
10 | this.#internals.role = 'textbox';
11 | }
12 |
13 | /* ... */
14 |
15 | #update() {
16 | /* ... */
17 | this.#internals.setFormValue(this.value);
18 | }
19 |
20 | static formAssociated = true;
21 | });
--------------------------------------------------------------------------------
/public/blog/articles/2025-05-09-form-control/demo2/input-inline.js:
--------------------------------------------------------------------------------
1 | customElements.define('input-inline', class extends HTMLElement {
2 |
3 | #internals;
4 |
5 | get value() {
6 | return this.getAttribute('value') ?? '';
7 | }
8 | set value(value) {
9 | this.setAttribute('value', String(value));
10 | }
11 |
12 | get name() {
13 | return this.getAttribute('name') ?? '';
14 | }
15 | set name(v) {
16 | this.setAttribute('name', String(v));
17 | }
18 |
19 | constructor() {
20 | super();
21 | this.#internals = this.attachInternals();
22 | this.#internals.role = 'textbox';
23 | }
24 |
25 | connectedCallback() {
26 | this.#update();
27 | }
28 |
29 | static observedAttributes = ['value'];
30 | attributeChangedCallback() {
31 | this.#update();
32 | }
33 |
34 | #update() {
35 | this.style.display = 'inline';
36 | if (this.textContent !== this.value) {
37 | this.textContent = this.value;
38 | }
39 | this.contentEditable = true;
40 | this.#internals.setFormValue(this.value);
41 | }
42 |
43 | static formAssociated = true;
44 | });
--------------------------------------------------------------------------------
/public/blog/articles/2025-05-09-form-control/demo3/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 | demo 3
7 |
8 |
16 |
17 |
18 |
26 |
27 |
28 |
29 |
38 |
39 |
40 |
--------------------------------------------------------------------------------
/public/blog/articles/2025-05-09-form-control/demo4/input-inline-partial.js:
--------------------------------------------------------------------------------
1 | customElements.define('input-inline', class extends HTMLElement {
2 |
3 | /* ... */
4 |
5 | #formDisabled = false;
6 | #value;
7 |
8 | set value(v) {
9 | if (this.#value !== String(v)) {
10 | this.#value = String(v);
11 | this.#update();
12 | }
13 | }
14 | get value() {
15 | return this.#value ?? this.defaultValue;
16 | }
17 |
18 | get defaultValue() {
19 | return this.getAttribute('value') ?? '';
20 | }
21 | set defaultValue(value) {
22 | this.setAttribute('value', String(value));
23 | }
24 |
25 | set disabled(v) {
26 | if (v) {
27 | this.setAttribute('disabled', 'true');
28 | } else {
29 | this.removeAttribute('disabled');
30 | }
31 | }
32 | get disabled() {
33 | return this.hasAttribute('disabled');
34 | }
35 |
36 | set readOnly(v) {
37 | if (v) {
38 | this.setAttribute('readonly', 'true');
39 | } else {
40 | this.removeAttribute('readonly');
41 | }
42 | }
43 | get readOnly() {
44 | return this.hasAttribute('readonly');
45 | }
46 |
47 | /* ... */
48 |
49 | static observedAttributes = ['value', 'disabled', 'readonly'];
50 | attributeChangedCallback() {
51 | this.#update();
52 | }
53 |
54 | #update() {
55 | this.style.display = 'inline';
56 | this.textContent = this.value;
57 | this.#internals.setFormValue(this.value);
58 |
59 | const isDisabled = this.#formDisabled || this.disabled;
60 | this.#internals.ariaDisabled = isDisabled;
61 | this.#internals.ariaReadOnly = this.readOnly;
62 | this.contentEditable = !this.readOnly && !isDisabled && 'plaintext-only';
63 | this.tabIndex = isDisabled ? -1 : 0;
64 | }
65 |
66 | static formAssociated = true;
67 |
68 | formResetCallback() {
69 | this.#value = undefined;
70 | this.#update();
71 | }
72 |
73 | formDisabledCallback(disabled) {
74 | this.#formDisabled = disabled;
75 | this.#update();
76 | }
77 |
78 | formStateRestoreCallback(state) {
79 | this.#value = state ?? undefined;
80 | this.#update();
81 | }
82 | });
83 |
84 | /* ... */
--------------------------------------------------------------------------------
/public/blog/articles/2025-05-09-form-control/demo5/input-inline.css:
--------------------------------------------------------------------------------
1 | /* default styling has lowest priority */
2 | @layer {
3 | :root {
4 | --input-inline-border-color: light-dark(rgb(118, 118, 118), rgb(161, 161, 161));
5 | --input-inline-border-color-hover: light-dark(rgb(78, 78, 78), rgb(200, 200, 200));
6 | --input-inline-border-color-disabled: rgba(150, 150, 150, 0.5);
7 | --input-inline-text-color: light-dark(fieldtext, rgb(240, 240, 240));
8 | --input-inline-text-color-disabled: light-dark(rgb(84, 84, 84), rgb(170, 170, 170));
9 | --input-inline-bg-color: inherit;
10 | --input-inline-bg-color-disabled: inherit;
11 | --input-inline-min-width: 4ch;
12 | }
13 |
14 | input-inline {
15 | display: inline;
16 | background-color: var(--input-inline-bg-color);
17 | color: var(--input-inline-text-color);
18 | border: 1px dotted var(--input-inline-border-color);
19 | padding: 2px 3px;
20 | margin-bottom: -2px;
21 | border-radius: 3px;
22 | /* minimum width */
23 | padding-right: max(3px, calc(var(--input-inline-min-width) - var(--current-length)));
24 |
25 | &:hover {
26 | border-color: var(--input-inline-border-color-hover);
27 | }
28 |
29 | &:disabled {
30 | border-color: var(--input-inline-border-color-disabled);
31 | background-color: var(--input-inline-bg-color-disabled);
32 | color: var(--input-inline-text-color-disabled);
33 | -webkit-user-select: none;
34 | user-select: none;
35 | }
36 |
37 | &:focus-visible {
38 | border-color: transparent;
39 | outline-offset: 0;
40 | outline: 2px solid royalblue; /* firefox */
41 | outline-color: -webkit-focus-ring-color; /* the rest */
42 | }
43 | }
44 |
45 | @media screen and (-webkit-min-device-pixel-ratio:0) {
46 | input-inline:empty::before {
47 | /* fixes issue where empty input-inline shifts left in chromium browsers */
48 | content: " ";
49 | }
50 | }
51 |
52 | }
53 |
--------------------------------------------------------------------------------
/public/blog/articles/2025-05-09-form-control/demo6/index-partial.txt:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/public/blog/articles/2025-05-09-form-control/demo6/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 | demo 6
7 |
8 |
9 |
20 |
21 |
22 |
29 |
30 | Set custom validity message
31 | Clear custom validity message
32 |
33 |
34 |
35 |
48 |
49 |
50 |
--------------------------------------------------------------------------------
/public/blog/articles/2025-05-09-form-control/demo6/input-inline.css:
--------------------------------------------------------------------------------
1 | /* default styling has lowest priority */
2 | @layer {
3 | :root {
4 | --input-inline-border-color: light-dark(rgb(118, 118, 118), rgb(161, 161, 161));
5 | --input-inline-border-color-hover: light-dark(rgb(78, 78, 78), rgb(200, 200, 200));
6 | --input-inline-border-color-disabled: rgba(150, 150, 150, 0.5);
7 | --input-inline-text-color: light-dark(fieldtext, rgb(240, 240, 240));
8 | --input-inline-text-color-disabled: light-dark(rgb(84, 84, 84), rgb(170, 170, 170));
9 | --input-inline-bg-color: inherit;
10 | --input-inline-bg-color-disabled: inherit;
11 | --input-inline-min-width: 4ch;
12 | }
13 |
14 | input-inline {
15 | display: inline;
16 | background-color: var(--input-inline-bg-color);
17 | color: var(--input-inline-text-color);
18 | border: 1px dotted var(--input-inline-border-color);
19 | padding: 2px 3px;
20 | margin-bottom: -2px;
21 | border-radius: 3px;
22 | /* minimum width */
23 | padding-right: max(3px, calc(var(--input-inline-min-width) - var(--current-length)));
24 |
25 | &:hover {
26 | border-color: var(--input-inline-border-color-hover);
27 | }
28 |
29 | &:disabled {
30 | border-color: var(--input-inline-border-color-disabled);
31 | background-color: var(--input-inline-bg-color-disabled);
32 | color: var(--input-inline-text-color-disabled);
33 | -webkit-user-select: none;
34 | user-select: none;
35 | }
36 |
37 | &:focus-visible {
38 | border-color: transparent;
39 | outline-offset: 0;
40 | outline: 2px solid royalblue; /* firefox */
41 | outline-color: -webkit-focus-ring-color; /* the rest */
42 | }
43 | }
44 |
45 | @media screen and (-webkit-min-device-pixel-ratio:0) {
46 | input-inline:empty::before {
47 | /* fixes issue where empty input-inline shifts left in chromium browsers */
48 | content: " ";
49 | }
50 | }
51 |
52 | }
53 |
--------------------------------------------------------------------------------
/public/blog/articles/2025-05-09-form-control/image.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/jsebrech/plainvanilla/48b88bbfc51210228cb7c5b8dd797a208c663bc5/public/blog/articles/2025-05-09-form-control/image.webp
--------------------------------------------------------------------------------
/public/blog/components/blog-archive.js:
--------------------------------------------------------------------------------
1 | import { html } from '../../lib/html.js';
2 |
3 | class BlogArchive extends HTMLElement {
4 | connectedCallback() {
5 | this.textContent = 'Loading...';
6 | fetch(import.meta.resolve('../articles/index.json'))
7 | .then(response => response.json())
8 | .then(articles => {
9 | // sort articles by published descending
10 | articles.sort((a, b) => {
11 | return -a.published.localeCompare(b.published);
12 | });
13 | this.innerHTML = '' +
14 | articles.map(item => html`
15 |
16 |
17 | ${item.summary}
18 |
19 |
20 | ${new Date(item.published).toLocaleDateString('en-US', { dateStyle: 'long' })}
21 |
22 |
23 |
24 | `).join('\n') +
25 | ' ';
26 | })
27 | .catch(e => {
28 | this.textContent = e.message;
29 | });
30 | }
31 | }
32 |
33 | export const registerBlogArchive =
34 | () => customElements.define('blog-archive', BlogArchive);
35 |
--------------------------------------------------------------------------------
/public/blog/components/blog-footer.js:
--------------------------------------------------------------------------------
1 | import { html } from '../../lib/html.js';
2 |
3 | class BlogFooter extends HTMLElement {
4 | connectedCallback() {
5 | const mastodonUrl = this.getAttribute('mastodon-url');
6 | this.innerHTML = html`
7 |
17 | `;
18 | }
19 | }
20 |
21 | export const registerBlogFooter = () => customElements.define('blog-footer', BlogFooter);
22 |
--------------------------------------------------------------------------------
/public/blog/components/blog-header.js:
--------------------------------------------------------------------------------
1 | import { html } from '../../lib/html.js';
2 |
3 | class BlogHeader extends HTMLElement {
4 | connectedCallback() {
5 | this.role = 'banner';
6 | const title = this.getAttribute('title') || 'Plain Vanilla Blog';
7 | const published = this.getAttribute('published');
8 | const updated = this.getAttribute('updated');
9 | const template = document.createElement('template');
10 | template.innerHTML = html`
11 | ${title}
12 | A blog about vanilla web development — no frameworks, just standards.
13 |
14 |
15 | Plain Vanilla
16 | Blog
17 |
18 |
19 | ${new Date(published).toLocaleDateString('en-US', { dateStyle: 'long' })}
20 |
21 |
22 |
23 | ${updated ? html`
24 |
25 | Last updated:
26 |
27 | ${new Date(updated).toLocaleDateString('en-US', { dateStyle: 'long' })}
28 |
29 |
30 | ` : ''}
31 |
32 | `;
33 | this.insertBefore(template.content, this.firstChild);
34 | }
35 | }
36 |
37 | export const registerBlogHeader = () => customElements.define('blog-header', BlogHeader);
38 |
--------------------------------------------------------------------------------
/public/blog/generator.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | Plain Vanilla Generator
5 |
6 |
7 |
31 |
32 |
33 |
34 | Generator
35 |
36 | Reset
37 |
38 |
39 |
40 | Browser support
41 |
42 | Chrome: supported
43 | Edge: supported
44 | Safari: not supported
45 | Firefox: not supported
46 | Brave: supported, but first enable File System Access in brave://flags
47 | Copy to clipboard: only over HTTPS
48 |
49 |
50 |
51 |
52 |
53 |
54 |
--------------------------------------------------------------------------------
/public/blog/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | Plain Vanilla Blog
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
24 |
25 | Featured
26 |
36 |
37 | Latest Posts
38 |
39 | Please enable scripting to view.
40 |
41 |
42 |
43 | Archive
44 | |
45 | RSS
46 |
47 |
48 |
49 |
50 |
51 |
52 |
--------------------------------------------------------------------------------
/public/blog/index.js:
--------------------------------------------------------------------------------
1 | import { registerBlogFooter } from "./components/blog-footer.js";
2 | import { registerBlogHeader } from "./components/blog-header.js";
3 | import { registerBlogLatestPosts } from "./components/blog-latest-posts.js";
4 | import { registerBlogArchive } from "./components/blog-archive.js";
5 | import { registerCodeViewerComponent } from "../components/code-viewer/code-viewer.js";
6 |
7 | const app = async () => {
8 | registerBlogLatestPosts();
9 | registerBlogHeader();
10 | registerBlogFooter();
11 | registerBlogArchive();
12 | registerCodeViewerComponent();
13 | const { registerAnalyticsComponent } = await import("../components/analytics/analytics.js");
14 | registerAnalyticsComponent();
15 | }
16 |
17 | document.addEventListener('DOMContentLoaded', app);
18 |
--------------------------------------------------------------------------------
/public/components/analytics/analytics.js:
--------------------------------------------------------------------------------
1 | class AnalyticsComponent extends HTMLElement {
2 |
3 | #template;
4 |
5 | constructor() {
6 | super();
7 | fetch(import.meta.resolve('../../analytics.template'))
8 | .then(res => res.ok && res.text())
9 | .then(template => {
10 | this.#template = template;
11 | this.update();
12 | })
13 | .catch(e => console.error(e));
14 | }
15 |
16 | connectedCallback() {
17 | this.update();
18 | }
19 |
20 | update() {
21 | if (this.isConnected && this.#template) {
22 | this.innerHTML = this.#template;
23 | // replace scripts by executable versions
24 | const scripts = this.getElementsByTagName('script');
25 | for (const script of scripts) {
26 | const newScript = document.createElement('script');
27 | for (const attr of script.attributes) {
28 | newScript.setAttribute(attr.name, attr.value);
29 | }
30 | newScript.innerHTML = script.innerHTML;
31 | script.replaceWith(newScript);
32 | }
33 | }
34 | }
35 | }
36 |
37 | export const registerAnalyticsComponent = () => {
38 | customElements.define('x-analytics', AnalyticsComponent);
39 | }
--------------------------------------------------------------------------------
/public/components/code-viewer/code-viewer.css:
--------------------------------------------------------------------------------
1 | @import "../../lib/speed-highlight/themes/github-dark.css";
2 |
3 | x-code-viewer {
4 | display: block;
5 | display: flex;
6 | flex-direction: column;
7 | }
8 |
9 | x-code-viewer label,
10 | x-code-viewer code {
11 | display: block;
12 | font-family: var(--font-system-code);
13 | font-size: var(--font-system-code-size);
14 | white-space: pre;
15 | padding: 1em;
16 | }
17 |
18 | x-code-viewer label {
19 | flex: 0 0 auto;
20 | border-bottom: 1px dotted var(--border-color);
21 | }
22 |
23 | x-code-viewer label:empty {
24 | display: none;
25 | }
26 |
27 | x-code-viewer code {
28 | position: relative;
29 | flex: 1 1 auto;
30 | overflow: auto;
31 | min-height: 8em;
32 | }
33 |
34 | x-code-viewer.loading code::after {
35 | content: '';
36 | box-sizing: border-box;
37 | width: 30px;
38 | height: 30px;
39 | position: absolute;
40 | top: calc(50% - 15px);
41 | left: calc(50% - 15px);
42 | border-radius: 50%;
43 | border-top: 4px solid ghostwhite;
44 | border-left: 4px solid ghostwhite;
45 | border-right: 4px solid ghostwhite;
46 | animation: code-viewer-spinner .6s linear infinite;
47 | }
48 |
49 | @keyframes code-viewer-spinner {
50 | to {transform: rotate(360deg);}
51 | }
52 |
53 | @media (scripting: none) {
54 | x-code-viewer::before { content: 'Enable scripting to view ' attr(src) }
55 | }
56 |
--------------------------------------------------------------------------------
/public/components/code-viewer/code-viewer.js:
--------------------------------------------------------------------------------
1 | import { highlightElement } from "../../lib/speed-highlight/index.js";
2 |
3 | /**
4 | * Code Viewer component
5 | *
6 | * Usage:
7 | * - show code with label "code.js"
8 | * - show code with label "My Code"
9 | */
10 | class CodeViewer extends HTMLElement {
11 | connectedCallback() {
12 | this.innerHTML = `
13 |
14 |
15 | `;
16 | // load code (and name) from src attribute
17 | const src = this.getAttribute('src');
18 | if (src) {
19 | if (!this.hasAttribute('name')) {
20 | this.setAttribute('name', src.split('/').pop());
21 | }
22 | this.classList.add('loading');
23 | fetch(src).then(res => res.text()).then(text => {
24 | this.setAttribute('code', text);
25 | }).catch((e) => this.setAttribute('code', e.message))
26 | .finally(() => this.classList.remove('loading'));
27 | }
28 | this.update();
29 | }
30 |
31 | static get observedAttributes() {
32 | return ['code', 'name'];
33 | }
34 |
35 | attributeChangedCallback() {
36 | this.update();
37 | }
38 |
39 | update() {
40 | const label = this.querySelector('label');
41 | const code = this.querySelector('code');
42 | if (label && code) {
43 | label.textContent = this.getAttribute('name');
44 | code.textContent = this.getAttribute('code');
45 | // should we syntax highlight?
46 | const src = this.getAttribute('src') || '';
47 | const lang = src.split('.').pop();
48 | if (['html', 'js', 'css'].includes(lang)) {
49 | code.className = 'shj-lang-' + lang;
50 | } else {
51 | code.className = 'shj-lang-plain';
52 | }
53 | highlightElement(code);
54 | }
55 | }
56 | }
57 |
58 | export const registerCodeViewerComponent =
59 | () => customElements.define('x-code-viewer', CodeViewer);
60 |
--------------------------------------------------------------------------------
/public/components/tab-panel/tab-panel.css:
--------------------------------------------------------------------------------
1 | x-tab-panel {
2 | display: block;
3 | border: 1px solid var(--border-color);
4 | }
5 |
6 | x-tab-panel div[role=tablist] {
7 | display: block;
8 | border-bottom: 1px dotted var(--border-color);
9 | }
10 |
11 | x-tab-panel div[role=tablist] button[role=tab] {
12 | font-family: var(--font-system);
13 | font-size: 100%;
14 | color: inherit;
15 | background-color: transparent;
16 | background-image: none;
17 | border: none;
18 | padding: 0.5em;
19 | padding-top: 0.6em;
20 | margin-left: 0.5em;
21 | border-bottom: 2px solid transparent;
22 | }
23 |
24 | x-tab-panel div[role=tablist] button[role=tab][aria-selected=true] {
25 | font-weight: bold;
26 | border-bottom: 2px solid black;
27 | }
28 |
29 | x-tab-panel > x-tab {
30 | display: none;
31 | }
32 |
33 | x-tab-panel > x-tab[active] {
34 | display: block !important;
35 | }
36 |
--------------------------------------------------------------------------------
/public/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/jsebrech/plainvanilla/48b88bbfc51210228cb7c5b8dd797a208c663bc5/public/favicon.ico
--------------------------------------------------------------------------------
/public/index.css:
--------------------------------------------------------------------------------
1 | @import "./styles/reset.css";
2 | @import "./styles/variables.css";
3 | @import "./styles/global.css";
4 | @import "./components/code-viewer/code-viewer.css";
5 | @import "./components/tab-panel/tab-panel.css";
6 |
--------------------------------------------------------------------------------
/public/index.js:
--------------------------------------------------------------------------------
1 | import { registerCodeViewerComponent } from "./components/code-viewer/code-viewer.js";
2 | import { registerTabPanelComponent } from "./components/tab-panel/tab-panel.js";
3 |
4 | const app = async () => {
5 | registerCodeViewerComponent();
6 | registerTabPanelComponent();
7 | const { registerAnalyticsComponent } = await import("./components/analytics/analytics.js");
8 | registerAnalyticsComponent();
9 | }
10 |
11 | document.addEventListener('DOMContentLoaded', app);
12 |
--------------------------------------------------------------------------------
/public/lib/akar-icons/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2020 Arturo Wibawa
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/public/lib/akar-icons/envelope.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
--------------------------------------------------------------------------------
/public/lib/akar-icons/github-fill.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
--------------------------------------------------------------------------------
/public/lib/html.js:
--------------------------------------------------------------------------------
1 | class Html extends String { }
2 |
3 | /**
4 | * tag a string as html not to be encoded
5 | * @param {string} str
6 | * @returns {string}
7 | */
8 | export const htmlRaw = str => new Html(str);
9 |
10 | /**
11 | * entity encode a string as html
12 | * @param {*} value The value to encode
13 | * @returns {string}
14 | */
15 | export const htmlEncode = (value) => {
16 | // avoid double-encoding the same string
17 | if (value instanceof Html) {
18 | return value;
19 | } else {
20 | // https://stackoverflow.com/a/57448862/20980
21 | return htmlRaw(
22 | String(value).replace(/[&<>'"]/g,
23 | tag => ({
24 | '&': '&',
25 | '<': '<',
26 | '>': '>',
27 | "'": ''',
28 | '"': '"'
29 | }[tag]))
30 | );
31 | }
32 | }
33 |
34 | /**
35 | * html tagged template literal, auto-encodes entities
36 | */
37 | export const html = (strings, ...values) =>
38 | htmlRaw(String.raw({ raw: strings }, ...values.map(htmlEncode)));
39 |
--------------------------------------------------------------------------------
/public/lib/speed-highlight/common.js:
--------------------------------------------------------------------------------
1 | /**
2 | * Commonly used match pattern
3 | */
4 |
5 | export default {
6 | num: {
7 | type: 'num',
8 | match: /(\.e?|\b)\d(e-|[\d.oxa-fA-F_])*(\.|\b)/g
9 | },
10 | str: {
11 | type: 'str',
12 | match: /(["'])(\\[^]|(?!\1)[^\r\n\\])*\1?/g
13 | },
14 | strDouble: {
15 | type: 'str',
16 | match: /"((?!")[^\r\n\\]|\\[^])*"?/g
17 | }
18 | }
19 |
--------------------------------------------------------------------------------
/public/lib/speed-highlight/languages/css.js:
--------------------------------------------------------------------------------
1 | export default [
2 | {
3 | match: /\/\*((?!\*\/)[^])*(\*\/)?/g,
4 | sub: 'todo'
5 | },
6 | {
7 | expand: 'str'
8 | },
9 | {
10 | type: 'kwd',
11 | match: /@\w+\b|\b(and|not|only|or)\b|\b[a-z-]+(?=[^{}]*{)/g
12 | },
13 | {
14 | type: 'var',
15 | match: /\b[\w-]+(?=\s*:)|(::?|\.)[\w-]+(?=[^{}]*{)/g
16 | },
17 | {
18 | type: 'func',
19 | match: /#[\w-]+(?=[^{}]*{)/g
20 | },
21 | {
22 | type: 'num',
23 | match: /#[\da-f]{3,8}/g
24 | },
25 | {
26 | type: 'num',
27 | match: /\d+(\.\d+)?(cm|mm|in|px|pt|pc|em|ex|ch|rem|vm|vh|vmin|vmax|%)?/g,
28 | sub: [
29 | {
30 | type: 'var',
31 | match: /[a-z]+|%/g
32 | }
33 | ]
34 | },
35 | {
36 | match: /url\([^)]*\)/g,
37 | sub: [
38 | {
39 | type: 'func',
40 | match: /url(?=\()/g
41 | },
42 | {
43 | type: 'str',
44 | match: /[^()]+/g
45 | }
46 | ]
47 | },
48 | {
49 | type: 'func',
50 | match: /\b[a-zA-Z]\w*(?=\s*\()/g
51 | },
52 | {
53 | type: 'num',
54 | match: /\b[a-z-]+\b/g
55 | }
56 | ]
57 |
--------------------------------------------------------------------------------
/public/lib/speed-highlight/languages/html.js:
--------------------------------------------------------------------------------
1 | import xml, { property, xmlElement } from './xml.js'
2 |
3 | export default [
4 | {
5 | type: 'class',
6 | match: /])*>/gi,
7 | sub: [
8 | {
9 | type: 'str',
10 | match: /"[^"]*"|'[^']*'/g
11 | },
12 | {
13 | type: 'oper',
14 | match: /^$/g
15 | },
16 | {
17 | type: 'var',
18 | match: /DOCTYPE/gi
19 | }
20 | ]
21 | },
22 | {
23 | match: RegExp(`)[^])*`, 'g'),
24 | sub: [
25 | {
26 | match: RegExp(`^$)`, 'g'),
31 | sub: 'css'
32 | },
33 | xmlElement
34 | ]
35 | },
36 | {
37 | match: RegExp(`)[^])*`, 'g'),
38 | sub: [
39 | {
40 | match: RegExp(`^$)`, 'g'),
45 | sub: 'js'
46 | },
47 | xmlElement
48 | ]
49 | },
50 | ...xml
51 | ]
--------------------------------------------------------------------------------
/public/lib/speed-highlight/languages/js.js:
--------------------------------------------------------------------------------
1 | export default [
2 | {
3 | match: /\/\*\*((?!\*\/)[^])*(\*\/)?/g,
4 | sub: 'jsdoc'
5 | },
6 | {
7 | match: /\/\/.*\n?|\/\*((?!\*\/)[^])*(\*\/)?/g,
8 | sub: 'todo'
9 | },
10 | {
11 | expand: 'str'
12 | },
13 | {
14 | match: /`((?!`)[^]|\\[^])*`?/g,
15 | sub: 'js_template_literals'
16 | },
17 | {
18 | type: 'kwd',
19 | match: /=>|\b(this|set|get|as|async|await|break|case|catch|class|const|constructor|continue|debugger|default|delete|do|else|enum|export|extends|finally|for|from|function|if|implements|import|in|instanceof|interface|let|var|of|new|package|private|protected|public|return|static|super|switch|throw|throws|try|typeof|void|while|with|yield)\b/g
20 | },
21 | {
22 | match: /\/((?!\/)[^\r\n\\]|\\.)+\/[dgimsuy]*/g,
23 | sub: 'regex'
24 | },
25 | {
26 | expand: 'num'
27 | },
28 | {
29 | type: 'num',
30 | match: /\b(NaN|null|undefined|[A-Z][A-Z_]*)\b/g
31 | },
32 | {
33 | type: 'bool',
34 | match: /\b(true|false)\b/g
35 | },
36 | {
37 | type: 'oper',
38 | match: /[/*+:?&|%^~=!,<>.^-]+/g
39 | },
40 | {
41 | type: 'class',
42 | match: /\b[A-Z][\w_]*\b/g
43 | },
44 | {
45 | type: 'func',
46 | match: /[a-zA-Z$_][\w$_]*(?=\s*((\?\.)?\s*\(|=\s*(\(?[\w,{}\[\])]+\)? =>|function\b)))/g
47 | }
48 | ]
49 |
--------------------------------------------------------------------------------
/public/lib/speed-highlight/languages/js_template_literals.js:
--------------------------------------------------------------------------------
1 | export default [
2 | {
3 | match: new class {
4 | exec(str) {
5 | let i = this.lastIndex,
6 | j,
7 | f = _ => {
8 | while (++i < str.length - 2)
9 | if (str[i] == '{') f();
10 | else if (str[i] == '}') return;
11 | };
12 | for (; i < str.length; i++)
13 | if (str[i - 1] != '\\' && str[i] == '$' && str[i + 1] == '{') {
14 | j = i++;
15 | f(i);
16 | this.lastIndex = i + 1;
17 | return { index: j, 0: str.slice(j, i + 1) };
18 | }
19 | return null;
20 | }
21 | }(),
22 | sub: [
23 | {
24 | type: 'kwd',
25 | match: /^\${|}$/g
26 | },
27 | {
28 | match: /(?!^\$|{)[^]+(?=}$)/g,
29 | sub: 'js'
30 | },
31 | ],
32 | },
33 | ];
34 | export let type = 'str';
35 |
--------------------------------------------------------------------------------
/public/lib/speed-highlight/languages/jsdoc.js:
--------------------------------------------------------------------------------
1 | import todo from './todo.js';
2 |
3 | export default [
4 | {
5 | type: 'kwd',
6 | match: /@\w+/g
7 | },
8 | {
9 | type: 'class',
10 | match: /{[\w\s|<>,.@\[\]]+}/g
11 | },
12 | {
13 | type: 'var',
14 | match: /\[[\w\s="']+\]/g
15 | },
16 | ...todo
17 | ];
18 | export let type = 'cmnt';
19 |
--------------------------------------------------------------------------------
/public/lib/speed-highlight/languages/json.js:
--------------------------------------------------------------------------------
1 | export default [
2 | {
3 | type: 'var',
4 | match: /("|')?[a-zA-Z]\w*\1(?=\s*:)/g
5 | },
6 | {
7 | expand: 'str'
8 | },
9 | {
10 | expand: 'num'
11 | },
12 | {
13 | type: 'num',
14 | match: /\bnull\b/g
15 | },
16 | {
17 | type: 'bool',
18 | match: /\b(true|false)\b/g
19 | }
20 | ]
21 |
--------------------------------------------------------------------------------
/public/lib/speed-highlight/languages/log.js:
--------------------------------------------------------------------------------
1 | export default [
2 | {
3 | type: 'cmnt',
4 | match: /^#.*/gm
5 | },
6 | {
7 | expand: 'strDouble'
8 | },
9 | {
10 | expand: 'num'
11 | },
12 | {
13 | type: 'err',
14 | match: /\b(err(or)?|[a-z_-]*exception|warn|warning|failed|ko|invalid|not ?found|alert|fatal)\b/gi
15 | },
16 | {
17 | type: 'num',
18 | match: /\b(null|undefined)\b/gi
19 | },
20 | {
21 | type: 'bool',
22 | match: /\b(false|true|yes|no)\b/gi
23 | },
24 | {
25 | type: 'oper',
26 | match: /\.|,/g
27 | }
28 | ]
29 |
--------------------------------------------------------------------------------
/public/lib/speed-highlight/languages/plain.js:
--------------------------------------------------------------------------------
1 | export default [
2 | {
3 | expand: 'strDouble'
4 | }
5 | ]
6 |
--------------------------------------------------------------------------------
/public/lib/speed-highlight/languages/regex.js:
--------------------------------------------------------------------------------
1 | export default [
2 | {
3 | match: /^(?!\/).*/gm,
4 | sub: 'todo'
5 | },
6 | {
7 | type: 'num',
8 | match: /\[((?!\])[^\\]|\\.)*\]/g
9 | },
10 | {
11 | type: 'kwd',
12 | match: /\||\^|\$|\\.|\w+($|\r|\n)/g
13 | },
14 | {
15 | type: 'var',
16 | match: /\*|\+|\{\d+,\d+\}/g
17 | }
18 | ];
19 | export let type = 'oper';
20 |
--------------------------------------------------------------------------------
/public/lib/speed-highlight/languages/todo.js:
--------------------------------------------------------------------------------
1 | export default [
2 | {
3 | type: 'err',
4 | match: /\b(TODO|FIXME|DEBUG|OPTIMIZE|WARNING|XXX|BUG)\b/g
5 | },
6 | {
7 | type: 'class',
8 | match: /\bIDEA\b/g
9 | },
10 | {
11 | type: 'insert',
12 | match: /\b(CHANGED|FIX|CHANGE)\b/g
13 | },
14 | {
15 | type: 'oper',
16 | match: /\bQUESTION\b/g
17 | }
18 | ];
19 | export let type = 'cmnt';
20 |
--------------------------------------------------------------------------------
/public/lib/speed-highlight/languages/ts.js:
--------------------------------------------------------------------------------
1 | import js from './js.js'
2 |
3 | export default [
4 | {
5 | type: 'type',
6 | match: /:\s*(any|void|number|boolean|string|object|never|enum)\b/g
7 | },
8 | {
9 | type: 'kwd',
10 | match: /\b(type|namespace|typedef|interface|public|private|protected|implements|declare|abstract|readonly)\b/g
11 | },
12 | ...js
13 | ]
14 |
--------------------------------------------------------------------------------
/public/lib/speed-highlight/languages/uri.js:
--------------------------------------------------------------------------------
1 | export default [
2 | {
3 | match: /^#.*/gm,
4 | sub: 'todo'
5 | },
6 | {
7 | type: 'class',
8 | match: /^\w+(?=:?)/gm
9 | },
10 | {
11 | type: 'num',
12 | match: /:\d+/g
13 | },
14 | {
15 | type: 'oper',
16 | match: /[:/&?]|\w+=/g
17 | },
18 | {
19 | type: 'func',
20 | match: /[.\w]+@|#[\w]+$/gm
21 | },
22 | {
23 | type: 'var',
24 | match: /\w+\.\w+(\.\w+)*/g
25 | }
26 | ]
27 |
--------------------------------------------------------------------------------
/public/lib/speed-highlight/languages/xml.js:
--------------------------------------------------------------------------------
1 | export let property = '\\s*(\\s+[a-z-]+\\s*(=\\s*([^"\']\\S*|("|\')(\\\\[^]|(?!\\4)[^])*\\4?)?)?\\s*)*',
2 | xmlElement = {
3 | match: RegExp(`<\/?[a-z_-]+${property}\/?>`, 'g'),
4 | sub: [
5 | {
6 | type: 'var',
7 | match: /^<\/?[^\s>\/]+/g,
8 | sub: [
9 | {
10 | type: 'oper',
11 | match: /^<\/?/g
12 | }
13 | ]
14 | },
15 | {
16 | type: 'str',
17 | match: /=\s*([^"']\S*|("|')(\\[^]|(?!\2)[^])*\2?)/g,
18 | sub: [
19 | {
20 | type: 'oper',
21 | match: /^=/g
22 | }
23 | ]
24 | },
25 | {
26 | type: 'oper',
27 | match: /\/?>/g
28 | },
29 | {
30 | type: 'class',
31 | match: /[a-z-]+/gi
32 | }
33 | ]
34 | };
35 |
36 | export default [
37 | {
38 | match: /)[^])*-->/g,
39 | sub: 'todo'
40 | },
41 | {
42 | type: 'class',
43 | match: RegExp(`<\\?xml${property}\\?>`, 'gi'),
44 | sub: [
45 | {
46 | type: 'oper',
47 | match: /^<\?|\?>$/g
48 | },
49 | {
50 | type: 'str',
51 | match: /"[^"]*"|'[^']*'/g
52 | },
53 | {
54 | type: 'var',
55 | match: /xml/gi
56 | }
57 | ]
58 | },
59 | {
60 | type: 'class',
61 | match: //gi
62 | },
63 | xmlElement,
64 | {
65 | type: 'var',
66 | match: /&(#x?)?[\da-z]{1,8};/gi
67 | }
68 | ]
--------------------------------------------------------------------------------
/public/lib/speed-highlight/themes/default.css:
--------------------------------------------------------------------------------
1 | [class*="shj-lang-"] {
2 | white-space: pre;
3 | /* margin: 10px 0;*/
4 | /* border-radius: 10px;*/
5 | /* padding: 30px 20px;*/
6 | background: white;
7 | color: #112;
8 | /* box-shadow: 0 0 5px #0001;*/
9 | text-shadow: none;
10 | /* font: normal 18px Consolas, "Courier New", Monaco, 'Andale Mono', 'Ubuntu Mono', monospace;*/
11 | line-height: 24px;
12 | box-sizing: border-box;
13 | max-width: min(100%, 100vw)
14 | }
15 | .shj-inline {
16 | /* margin: 0;*/
17 | /* padding: 2px 5px;*/
18 | display: inline-block;
19 | /* border-radius: 5px*/
20 | }
21 |
22 | [class*="shj-lang-"]::selection,
23 | [class*="shj-lang-"] ::selection {background: #bdf5}
24 | [class*="shj-lang-"] > div {
25 | display: flex;
26 | overflow: auto
27 | }
28 | [class*="shj-lang-"] > div :last-child {
29 | flex: 1;
30 | outline: none
31 | }
32 | .shj-numbers {
33 | padding-left: 5px;
34 | counter-reset: line
35 | }
36 | .shj-numbers div {padding-right: 5px}
37 | .shj-numbers div::before {
38 | color: #999;
39 | display: block;
40 | content: counter(line);
41 | opacity: .5;
42 | text-align: right;
43 | margin-right: 5px;
44 | counter-increment: line
45 | }
46 |
47 | .shj-syn-cmnt {font-style: italic}
48 |
49 | .shj-syn-err,
50 | .shj-syn-kwd {color: #e16}
51 | .shj-syn-num,
52 | .shj-syn-class {color: #f60}
53 | .shj-numbers,
54 | .shj-syn-cmnt {color: #999}
55 | .shj-syn-insert,
56 | .shj-syn-str {color: #7d8}
57 | .shj-syn-bool {color: #3bf}
58 | .shj-syn-type,
59 | .shj-syn-oper {color: #5af}
60 | .shj-syn-section,
61 | .shj-syn-func {color: #84f}
62 | .shj-syn-deleted,
63 | .shj-syn-var {color: #f44}
64 |
65 | .shj-oneline {padding: 12px 10px}
66 | .shj-lang-http.shj-oneline .shj-syn-kwd {
67 | background: #25f;
68 | color: #fff;
69 | padding: 5px 7px;
70 | border-radius: 5px
71 | }
72 |
--------------------------------------------------------------------------------
/public/lib/speed-highlight/themes/github-dark.css:
--------------------------------------------------------------------------------
1 | @import 'default.css';
2 |
3 | [class*="shj-lang-"] {
4 | color: #c9d1d9;
5 | background: #161b22
6 | }
7 | [class*="shj-lang-"]:before {color: #6f9aff}
8 |
9 | .shj-syn-insert {color: #98c379}
10 | .shj-syn-deleted,
11 | .shj-syn-err,
12 | .shj-syn-kwd {color: #ff7b72}
13 | .shj-syn-class {color: #ffa657}
14 | .shj-numbers,
15 | .shj-syn-cmnt {color: #8b949e}
16 | .shj-syn-type,
17 | .shj-syn-oper,
18 | .shj-syn-num,
19 | .shj-syn-section,
20 | .shj-syn-var,
21 | .shj-syn-bool {color: #79c0ff}
22 | .shj-syn-str {color: #a5d6ff}
23 | .shj-syn-func {color: #d2a8ff}
24 |
--------------------------------------------------------------------------------
/public/lib/speed-highlight/themes/github-light.css:
--------------------------------------------------------------------------------
1 | @import 'default.css';
2 |
3 | [class*="shj-lang-"] {
4 | color: #24292f;
5 | background: #fff
6 | }
7 |
8 | .shj-syn-deleted,
9 | .shj-syn-err,
10 | .shj-syn-kwd {color: #cf222e}
11 | .shj-syn-class {color: #953800}
12 | .shj-numbers,
13 | .shj-syn-cmnt {color: #6e7781}
14 | .shj-syn-type,
15 | .shj-syn-oper,
16 | .shj-syn-num,
17 | .shj-syn-section,
18 | .shj-syn-var,
19 | .shj-syn-bool {color: #0550ae}
20 | .shj-syn-str {color: #0a3069}
21 | .shj-syn-func {color: #8250df}
22 |
--------------------------------------------------------------------------------
/public/manifest.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "Plain Vanilla",
3 | "short_name": "Plain Vanilla",
4 | "icons": [{
5 | "src": "android-chrome-512x512.png",
6 | "sizes": "512x512"
7 | }],
8 | "background_color": "#ffffff",
9 | "description": "An explainer for doing web development using only vanilla techniques.",
10 | "theme_color": "#ffffff",
11 | "display": "fullscreen"
12 | }
--------------------------------------------------------------------------------
/public/pages/examples/applications/counter/components/counter.js:
--------------------------------------------------------------------------------
1 | class Counter extends HTMLElement {
2 | #count = 0;
3 |
4 | increment() {
5 | this.#count++;
6 | this.update();
7 | }
8 |
9 | connectedCallback() {
10 | this.update();
11 | }
12 |
13 | update() {
14 | this.textContent = this.#count;
15 | }
16 | }
17 |
18 | export const registerCounterComponent =
19 | () => customElements.define('x-counter', Counter);
20 |
--------------------------------------------------------------------------------
/public/pages/examples/applications/counter/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
8 |
9 |
10 |
11 | Let's count to .
12 |
13 | Increment
14 |
15 |
16 |
--------------------------------------------------------------------------------
/public/pages/examples/applications/counter/index.js:
--------------------------------------------------------------------------------
1 | import { registerCounterComponent } from './components/counter.js';
2 |
3 | const app = () => {
4 | registerCounterComponent();
5 | }
6 |
7 | document.addEventListener('DOMContentLoaded', app);
8 |
--------------------------------------------------------------------------------
/public/pages/examples/applications/lifting-state-up/components/accordion.js:
--------------------------------------------------------------------------------
1 | class Accordion extends HTMLElement {
2 | #activeIndex = 0;
3 |
4 | get activeIndex () { return this.#activeIndex; }
5 | set activeIndex(index) { this.#activeIndex = index; this.update(); }
6 |
7 | connectedCallback() {
8 | this.innerHTML = `
9 | Almaty, Kazakhstan
10 |
12 | With a population of about 2 million, Almaty is Kazakhstan's largest city. From 1929 to 1997, it was its capital city.
13 |
14 |
16 | The name comes from алма , the Kazakh word for "apple" and is often translated as "full of apples". In fact, the region surrounding Almaty is thought to be the ancestral home of the apple, and the wild Malus sieversii is considered a likely candidate for the ancestor of the modern domestic apple.
17 |
18 | `;
19 | this.querySelectorAll('x-panel').forEach((panel, index) => {
20 | panel.addEventListener('show', () => {
21 | this.activeIndex = index;
22 | });
23 | })
24 | this.update();
25 | }
26 |
27 | update() {
28 | this.querySelectorAll('x-panel').forEach((panel, index) => {
29 | panel.setAttribute('active', index === this.activeIndex);
30 | });
31 | }
32 | }
33 |
34 | export const registerAccordionComponent = () => {
35 | customElements.define('x-accordion', Accordion);
36 | }
37 |
--------------------------------------------------------------------------------
/public/pages/examples/applications/lifting-state-up/components/panel.js:
--------------------------------------------------------------------------------
1 | class Panel extends HTMLElement {
2 | constructor() {
3 | super();
4 | this.attachShadow({ mode: 'open' });
5 | }
6 |
7 | connectedCallback() {
8 | this.shadowRoot.innerHTML = `
9 |
14 | `;
15 | this.shadowRoot.querySelector('button').onclick =
16 | () => this.dispatchEvent(new CustomEvent('show'));
17 | this.update();
18 | }
19 |
20 | static get observedAttributes() { return ['title', 'active']; }
21 |
22 | attributeChangedCallback() {
23 | this.update();
24 | }
25 |
26 | update() {
27 | const heading = this.shadowRoot.querySelector('h3');
28 | const slot = this.shadowRoot.querySelector('slot');
29 | const button = this.shadowRoot.querySelector('button');
30 | if (heading && slot && button) {
31 | heading.textContent = this.title;
32 | slot.style.display = this.getAttribute('active') === 'true' ? 'block' : 'none';
33 | button.style.display = this.getAttribute('active') === 'true' ? 'none' : 'inline';
34 | }
35 | }
36 | }
37 |
38 | export const registerPanelComponent =
39 | () => customElements.define('x-panel', Panel);
40 |
--------------------------------------------------------------------------------
/public/pages/examples/applications/lifting-state-up/index.css:
--------------------------------------------------------------------------------
1 | body {
2 | font-family: system-ui, sans-serif;
3 | }
--------------------------------------------------------------------------------
/public/pages/examples/applications/lifting-state-up/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
--------------------------------------------------------------------------------
/public/pages/examples/applications/lifting-state-up/index.js:
--------------------------------------------------------------------------------
1 | import { registerAccordionComponent } from './components/accordion.js';
2 | import { registerPanelComponent } from './components/panel.js';
3 |
4 | const app = () => {
5 | registerAccordionComponent();
6 | registerPanelComponent();
7 | }
8 |
9 | document.addEventListener('DOMContentLoaded', app);
10 |
--------------------------------------------------------------------------------
/public/pages/examples/applications/lifting-state-up/react/App.js:
--------------------------------------------------------------------------------
1 | import { useState } from 'react';
2 |
3 | export default function Accordion() {
4 | const [activeIndex, setActiveIndex] = useState(0);
5 | return (
6 | <>
7 | Almaty, Kazakhstan
8 | setActiveIndex(0)}
12 | >
13 | With a population of about 2 million, Almaty is Kazakhstan's largest city. From 1929 to 1997, it was its capital city.
14 |
15 | setActiveIndex(1)}
19 | >
20 | The name comes from алма , the Kazakh word for "apple" and is often translated as "full of apples". In fact, the region surrounding Almaty is thought to be the ancestral home of the apple, and the wild Malus sieversii is considered a likely candidate for the ancestor of the modern domestic apple.
21 |
22 | >
23 | );
24 | }
25 |
26 | function Panel({
27 | title,
28 | children,
29 | isActive,
30 | onShow
31 | }) {
32 | return (
33 |
34 | {title}
35 | {isActive ? (
36 | {children}
37 | ) : (
38 |
39 | Show
40 |
41 | )}
42 |
43 | );
44 | }
45 |
--------------------------------------------------------------------------------
/public/pages/examples/applications/passing-data-deeply/components/button.js:
--------------------------------------------------------------------------------
1 | import { ContextRequestEvent } from "../lib/tiny-context.js";
2 |
3 | class ButtonComponent extends HTMLElement {
4 | constructor() {
5 | super();
6 | this.attachShadow({ mode: 'open' });
7 | }
8 |
9 | #theme = 'light';
10 | #unsubscribe;
11 |
12 | connectedCallback() {
13 | this.shadowRoot.innerHTML = `
14 |
15 |
16 |
17 |
18 | `;
19 | this.dispatchEvent(new ContextRequestEvent('theme', (theme, unsubscribe) => {
20 | this.#theme = theme;
21 | this.#unsubscribe = unsubscribe;
22 | this.update();
23 | }, true));
24 | this.dispatchEvent(new ContextRequestEvent('theme-toggle', (toggle) => {
25 | this.shadowRoot.querySelector('button').onclick = toggle;
26 | }));
27 | this.update();
28 | }
29 |
30 | disconnectedCallback() {
31 | this.#unsubscribe?.();
32 | }
33 |
34 | update() {
35 | const button = this.shadowRoot.querySelector('button');
36 | if (button) button.className = 'button-' + this.#theme;
37 | }
38 | }
39 |
40 | export const registerButtonComponent =
41 | () => customElements.define('x-button', ButtonComponent);
42 |
--------------------------------------------------------------------------------
/public/pages/examples/applications/passing-data-deeply/components/panel.js:
--------------------------------------------------------------------------------
1 | import { ContextRequestEvent } from "../lib/tiny-context.js";
2 |
3 | class PanelComponent extends HTMLElement {
4 | constructor() {
5 | super();
6 | this.attachShadow({ mode: 'open' });
7 | }
8 |
9 | #theme = 'light';
10 | #unsubscribe;
11 |
12 | connectedCallback() {
13 | this.shadowRoot.innerHTML = `
14 |
15 |
19 | `;
20 | this.dispatchEvent(new ContextRequestEvent('theme', (theme, unsubscribe) => {
21 | this.#theme = theme;
22 | this.#unsubscribe = unsubscribe;
23 | this.update();
24 | }, true));
25 | this.update();
26 | }
27 |
28 | disconnectedCallback() {
29 | this.#unsubscribe?.();
30 | }
31 |
32 | static get observedAttributes() {
33 | return ['title'];
34 | }
35 |
36 | attributeChangedCallback() {
37 | this.update();
38 | }
39 |
40 | update() {
41 | const h1 = this.shadowRoot.querySelector('h1');
42 | const section = this.shadowRoot.querySelector('section');
43 | if (section && h1) {
44 | section.className = 'panel-' + this.#theme;
45 | h1.textContent = this.getAttribute('title');
46 | }
47 | }
48 | }
49 |
50 | export const registerPanelComponent =
51 | () => customElements.define('x-panel', PanelComponent);
52 |
--------------------------------------------------------------------------------
/public/pages/examples/applications/passing-data-deeply/components/theme-context.js:
--------------------------------------------------------------------------------
1 | import { ContextProvider } from "../lib/tiny-context.js";
2 |
3 | class ThemeContext extends HTMLElement {
4 |
5 | themeProvider = new ContextProvider(this, 'theme', 'light');
6 | toggleProvider = new ContextProvider(this, 'theme-toggle', () => {
7 | this.themeProvider.value = this.themeProvider.value === 'light' ? 'dark' : 'light';
8 | });
9 |
10 | connectedCallback() {
11 | this.style.display = 'contents';
12 | }
13 | }
14 |
15 | export const registerThemeContext =
16 | () => customElements.define('x-theme-context', ThemeContext);
17 |
--------------------------------------------------------------------------------
/public/pages/examples/applications/passing-data-deeply/index.css:
--------------------------------------------------------------------------------
1 | :root {
2 | font-family: sans-serif;
3 | }
4 |
5 | body {
6 | margin: 20px;
7 | padding: 0;
8 | }
9 |
10 | * {
11 | box-sizing: border-box;
12 | }
13 |
14 | h1 {
15 | margin-top: 0;
16 | font-size: 22px;
17 | }
18 |
19 | .panel-light,
20 | .panel-dark {
21 | border: 1px solid black;
22 | border-radius: 4px;
23 | padding: 20px;
24 | }
25 | .panel-light {
26 | color: #222;
27 | background: #fff;
28 | }
29 |
30 | .panel-dark {
31 | color: #fff;
32 | background: rgb(23, 32, 42);
33 | }
34 |
35 | .button-light,
36 | .button-dark {
37 | border: 1px solid #777;
38 | padding: 5px;
39 | margin-right: 10px;
40 | margin-top: 10px;
41 | }
42 |
43 | .button-dark {
44 | background: #222;
45 | color: #fff;
46 | }
47 |
48 | .button-light {
49 | background: #fff;
50 | color: #222;
51 | }
52 |
--------------------------------------------------------------------------------
/public/pages/examples/applications/passing-data-deeply/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 | Toggle theme
11 |
12 |
13 |
14 |
--------------------------------------------------------------------------------
/public/pages/examples/applications/passing-data-deeply/index.js:
--------------------------------------------------------------------------------
1 | import { registerThemeContext } from './components/theme-context.js';
2 | import { registerPanelComponent } from './components/panel.js';
3 | import { registerButtonComponent } from './components/button.js';
4 |
5 | const app = () => {
6 | registerThemeContext();
7 | registerPanelComponent();
8 | registerButtonComponent();
9 | }
10 |
11 | document.addEventListener('DOMContentLoaded', app);
12 |
--------------------------------------------------------------------------------
/public/pages/examples/applications/passing-data-deeply/lib/tiny-context.js:
--------------------------------------------------------------------------------
1 | export class ContextRequestEvent extends Event {
2 | constructor(context, callback, subscribe) {
3 | super('context-request', {
4 | bubbles: true,
5 | composed: true,
6 | });
7 | this.context = context;
8 | this.callback = callback;
9 | this.subscribe = subscribe;
10 | }
11 | }
12 |
13 | export class ContextProvider extends EventTarget {
14 | #value;
15 | get value() { return this.#value }
16 | set value(v) { this.#value = v; this.dispatchEvent(new Event('change')); }
17 |
18 | #context;
19 | get context() { return this.#context }
20 |
21 | constructor(target, context, initialValue = undefined) {
22 | super();
23 | this.#context = context;
24 | this.#value = initialValue;
25 | if (target) this.attach(target);
26 | }
27 |
28 | attach(target) {
29 | target.addEventListener('context-request', this);
30 | }
31 |
32 | detach(target) {
33 | target.removeEventListener('context-request', this);
34 | }
35 |
36 | /**
37 | * Handle a context-request event
38 | * @param {ContextRequestEvent} e
39 | */
40 | handleEvent(e) {
41 | if (e.context === this.context) {
42 | if (e.subscribe) {
43 | const unsubscribe = () => this.removeEventListener('change', update);
44 | const update = () => e.callback(this.value, unsubscribe);
45 | this.addEventListener('change', update);
46 | update();
47 | } else {
48 | e.callback(this.value);
49 | }
50 | e.stopPropagation();
51 | }
52 | }
53 | }
54 |
--------------------------------------------------------------------------------
/public/pages/examples/applications/single-page/app/App.js:
--------------------------------------------------------------------------------
1 | class App extends HTMLElement {
2 | connectedCallback() {
3 | this.innerHTML = `
4 | Basic Example
5 |
6 |
7 | This example demonstrates how the features of a framework router
8 | can be approximated using web components and a vanilla hash router.
9 |
10 |
11 | Check out the original React Router basic example for comparison.
12 |
13 |
14 |
15 |
16 |
17 |
18 | Home
19 |
20 |
21 | About
22 |
23 |
24 | Dashboard
25 |
26 |
27 | Nothing to see here
28 | Go to the home page
29 |
30 |
31 | `;
32 | }
33 | }
34 |
35 | class AppLayout extends HTMLElement {
36 | connectedCallback() {
37 | this.innerHTML = `
38 |
39 |
45 |
46 |
47 | `;
48 | }
49 | }
50 |
51 | export const registerApp = () => {
52 | customElements.define('x-app', App);
53 | customElements.define('x-app-layout', AppLayout);
54 | }
55 |
--------------------------------------------------------------------------------
/public/pages/examples/applications/single-page/index.css:
--------------------------------------------------------------------------------
1 | body {
2 | font-family: system-ui, sans-serif;
3 | }
--------------------------------------------------------------------------------
/public/pages/examples/applications/single-page/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | Single-page Example
5 |
6 |
7 |
8 |
9 | Please enable JavaScript to view this page.
10 |
11 |
12 |
13 |
14 |
--------------------------------------------------------------------------------
/public/pages/examples/applications/single-page/index.js:
--------------------------------------------------------------------------------
1 | import { registerApp } from "./app/App.js";
2 | import { registerRouteComponent } from "./components/route/route.js";
3 |
4 | const app = () => {
5 | registerRouteComponent();
6 | registerApp();
7 |
8 | const template = document.querySelector('template#root');
9 | if (template) document.body.appendChild(template.content, true);
10 | }
11 |
12 | document.addEventListener('DOMContentLoaded', app);
13 |
--------------------------------------------------------------------------------
/public/pages/examples/components/adding-children/components/avatar.css:
--------------------------------------------------------------------------------
1 | x-avatar {
2 | display: flex;
3 | align-items: center;
4 | justify-content: center;
5 | width: 2.5rem;
6 | height: 2.5rem;
7 | }
8 |
9 | x-avatar[size=lg] {
10 | width: 3.5rem;
11 | height: 3.5rem;
12 | }
13 |
14 | x-avatar img {
15 | border-radius: 9999px;
16 | width: 100%;
17 | height: 100%;
18 | vertical-align: middle;
19 | object-fit: cover;
20 | }
21 |
--------------------------------------------------------------------------------
/public/pages/examples/components/adding-children/components/avatar.js:
--------------------------------------------------------------------------------
1 | /**
2 | * Usage:
3 | *
4 | *
5 | */
6 | class AvatarComponent extends HTMLElement {
7 | connectedCallback() {
8 | if (!this.querySelector('img')) {
9 | this.append(document.createElement('img'));
10 | }
11 | this.update();
12 | }
13 |
14 | static get observedAttributes() {
15 | return ['src', 'alt'];
16 | }
17 |
18 | attributeChangedCallback() {
19 | this.update();
20 | }
21 |
22 | update() {
23 | const img = this.querySelector('img');
24 | if (img) {
25 | img.src = this.getAttribute('src');
26 | img.alt = this.getAttribute('alt') || 'avatar';
27 | }
28 | }
29 | }
30 |
31 | export const registerAvatarComponent = () => {
32 | customElements.define('x-avatar', AvatarComponent);
33 | }
34 |
--------------------------------------------------------------------------------
/public/pages/examples/components/adding-children/components/badge.css:
--------------------------------------------------------------------------------
1 | x-badge {
2 | position: relative;
3 | display: inline-flex;
4 | flex-shrink: 0;
5 | box-sizing: border-box;
6 | }
7 |
8 | x-badge > span.x-badge-label {
9 | /* size and position */
10 | box-sizing: inherit;
11 | position: absolute;
12 | top: 0.2rem;
13 | right: 0.2rem;
14 | width: 1.25rem;
15 | height: 1.25rem;
16 | transform: translate(50%, -50%);
17 | z-index: 10;
18 | /* colors and fonts */
19 | color: white;
20 | background-color: rgb(0, 111, 238);
21 | border-style: solid;
22 | border-color: #333333;
23 | border-width: 2px;
24 | border-radius: 9999px;
25 | font-size: 0.875rem;
26 | line-height: 1.2;
27 | /* text placement */
28 | display: flex;
29 | place-content: center;
30 | user-select: none;
31 | }
32 |
--------------------------------------------------------------------------------
/public/pages/examples/components/adding-children/components/badge.js:
--------------------------------------------------------------------------------
1 | class BadgeComponent extends HTMLElement {
2 | #span;
3 |
4 | connectedCallback() {
5 | if (!this.#span) {
6 | this.#span = document.createElement('span');
7 | this.#span.className = 'x-badge-label';
8 | }
9 | this.insertBefore(this.#span, this.firstChild);
10 | this.update();
11 | }
12 |
13 | update() {
14 | if (this.#span) this.#span.textContent = this.getAttribute('content');
15 | }
16 |
17 | static get observedAttributes() {
18 | return ['content'];
19 | }
20 |
21 | attributeChangedCallback() {
22 | this.update();
23 | }
24 |
25 | set content(value) {
26 | if (this.getAttribute('content') !== value) {
27 | this.setAttribute('content', value);
28 | }
29 | }
30 |
31 | get content() {
32 | return this.getAttribute('content');
33 | }
34 | }
35 |
36 | export const registerBadgeComponent = () => customElements.define('x-badge', BadgeComponent);
37 |
--------------------------------------------------------------------------------
/public/pages/examples/components/adding-children/index.css:
--------------------------------------------------------------------------------
1 | @import "./components/avatar.css";
2 | @import "./components/badge.css";
3 |
4 | p, div { margin: 1em; font-family: sans-serif; }
5 | x-badge { vertical-align: middle; }
6 |
--------------------------------------------------------------------------------
/public/pages/examples/components/adding-children/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 | Avatar and badge, when their powers combine...
9 |
10 |
11 |
12 |
13 | ←
14 |
16 |
17 |
18 |
--------------------------------------------------------------------------------
/public/pages/examples/components/adding-children/index.js:
--------------------------------------------------------------------------------
1 | import { registerAvatarComponent } from './components/avatar.js';
2 | import { registerBadgeComponent } from './components/badge.js';
3 | const app = () => {
4 | registerAvatarComponent();
5 | registerBadgeComponent();
6 | }
7 | document.addEventListener('DOMContentLoaded', app);
8 |
--------------------------------------------------------------------------------
/public/pages/examples/components/advanced/components/avatar.css:
--------------------------------------------------------------------------------
1 | x-avatar {
2 | display: flex;
3 | align-items: center;
4 | justify-content: center;
5 | width: 2.5rem;
6 | height: 2.5rem;
7 | }
8 |
9 | x-avatar[size=lg] {
10 | width: 3.5rem;
11 | height: 3.5rem;
12 | }
13 |
14 | x-avatar img {
15 | border-radius: 9999px;
16 | width: 100%;
17 | height: 100%;
18 | vertical-align: middle;
19 | object-fit: cover;
20 | }
21 |
--------------------------------------------------------------------------------
/public/pages/examples/components/advanced/components/avatar.js:
--------------------------------------------------------------------------------
1 | /**
2 | * Usage:
3 | *
4 | *
5 | */
6 | class AvatarComponent extends HTMLElement {
7 | connectedCallback() {
8 | if (!this.querySelector('img')) {
9 | this.append(document.createElement('img'));
10 | }
11 | this.update();
12 | }
13 |
14 | static get observedAttributes() {
15 | return ['src', 'alt'];
16 | }
17 |
18 | attributeChangedCallback() {
19 | this.update();
20 | }
21 |
22 | update() {
23 | const img = this.querySelector('img');
24 | if (img) {
25 | img.src = this.getAttribute('src');
26 | img.alt = this.getAttribute('alt') || 'avatar';
27 | }
28 | }
29 | }
30 |
31 | export const registerAvatarComponent = () => {
32 | customElements.define('x-avatar', AvatarComponent);
33 | }
34 |
--------------------------------------------------------------------------------
/public/pages/examples/components/advanced/index.css:
--------------------------------------------------------------------------------
1 | @import "./components/avatar.css";
2 | body { font-family: monospace; }
3 |
--------------------------------------------------------------------------------
/public/pages/examples/components/advanced/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 | A basic avatar component in two sizes:
9 |
10 |
11 |
12 |
--------------------------------------------------------------------------------
/public/pages/examples/components/advanced/index.js:
--------------------------------------------------------------------------------
1 | import { registerAvatarComponent } from './components/avatar.js';
2 | const app = () => {
3 | registerAvatarComponent();
4 | }
5 | document.addEventListener('DOMContentLoaded', app);
6 |
--------------------------------------------------------------------------------
/public/pages/examples/components/advanced/simple.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
--------------------------------------------------------------------------------
/public/pages/examples/components/data/components/app.js:
--------------------------------------------------------------------------------
1 | class SantasApp extends HTMLElement {
2 | #theList = [/* { name, nice } */];
3 |
4 | connectedCallback() {
5 | if (this.querySelector('h1')) return;
6 | this.innerHTML = `
7 | Santa's List
8 |
9 |
10 |
11 | `;
12 | this.querySelector('santas-form')
13 | .addEventListener('add', (e) => {
14 | this.#theList.push(e.detail.form);
15 | this.update();
16 | });
17 | this.update();
18 | }
19 |
20 | update() {
21 | this.querySelector('santas-list').list = this.#theList.slice();
22 | this.querySelector('santas-summary').update(this.#theList.slice());
23 | }
24 | }
25 |
26 | export const registerApp =
27 | () => customElements.define('santas-app', SantasApp);
28 |
--------------------------------------------------------------------------------
/public/pages/examples/components/data/components/form.js:
--------------------------------------------------------------------------------
1 | class SantasForm extends HTMLElement {
2 | connectedCallback() {
3 | if (this.querySelector('form')) return;
4 | this.innerHTML = `
5 |
12 | `;
13 | this.querySelector('form').onsubmit = (e) => {
14 | e.preventDefault();
15 | const data = new FormData(e.target);
16 | this.dispatchEvent(new CustomEvent('add', {
17 | detail: { form: Object.fromEntries(data.entries()) }
18 | }));
19 | e.target.reset();
20 | }
21 | }
22 | }
23 |
24 | export const registerSantasForm =
25 | () => customElements.define('santas-form', SantasForm);
26 |
--------------------------------------------------------------------------------
/public/pages/examples/components/data/components/list-safe.js:
--------------------------------------------------------------------------------
1 | import { html } from '../lib/html.js';
2 |
3 | class SantasList extends HTMLElement {
4 | #currentList = [/* { name, nice } */];
5 | set list(newList) {
6 | this.#currentList = newList;
7 | this.update();
8 | }
9 | update() {
10 | this.innerHTML =
11 | '' +
12 | this.#currentList.map(person =>
13 |
14 | // the html`` literal automatically encodes entities in the variables
15 | html`${person.name} is ${person.nice ? 'nice' : 'naughty'} `
16 |
17 | ).join('\n') +
18 | ' ';
19 | }
20 | }
21 |
22 | export const registerSantasList =
23 | () => customElements.define('santas-list', SantasList);
24 |
--------------------------------------------------------------------------------
/public/pages/examples/components/data/components/list.js:
--------------------------------------------------------------------------------
1 | class SantasList extends HTMLElement {
2 | #currentList = [/* { name, nice } */];
3 | set list(newList) {
4 | this.#currentList = newList;
5 | this.update();
6 | }
7 | update() {
8 | this.innerHTML =
9 | '' +
10 | this.#currentList.map(person =>
11 | `${person.name} is ${person.nice ? 'nice' : 'naughty'} `
12 | ).join('\n') +
13 | ' ';
14 | }
15 | }
16 |
17 | export const registerSantasList =
18 | () => customElements.define('santas-list', SantasList);
19 |
--------------------------------------------------------------------------------
/public/pages/examples/components/data/components/summary.js:
--------------------------------------------------------------------------------
1 | class SantasSummary extends HTMLElement {
2 | update(list) {
3 | const nice = list.filter((item) => item.nice).length;
4 | const naughty = list.length - nice;
5 | this.innerHTML = list.length ? `
6 | ${nice} nice, ${naughty} naughty
7 | ` : "Nobody's on the list yet.
";
8 | }
9 | }
10 |
11 | export const registerSantasSummary =
12 | () => customElements.define('santas-summary', SantasSummary);
13 |
--------------------------------------------------------------------------------
/public/pages/examples/components/data/index.css:
--------------------------------------------------------------------------------
1 | body {
2 | font-family: 'Iowan Old Style', 'Palatino Linotype', 'URW Palladio L', P052, serif;
3 | margin: 1em;
4 | }
5 |
6 | button { font-family: inherit; font-size: 100%; margin-left: 0.5em; }
7 |
8 | santas-form { display: block }
9 | santas-form * { vertical-align: middle; }
10 |
11 | santas-app h1 { color: darkred; }
12 |
--------------------------------------------------------------------------------
/public/pages/examples/components/data/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
--------------------------------------------------------------------------------
/public/pages/examples/components/data/index.js:
--------------------------------------------------------------------------------
1 | import { registerSantasForm } from './components/form.js';
2 | import { registerSantasList } from './components/list.js';
3 | import { registerSantasSummary } from './components/summary.js';
4 | import { registerApp } from './components/app.js';
5 |
6 | const app = () => {
7 | registerSantasForm();
8 | registerSantasList();
9 | registerSantasSummary();
10 | registerApp();
11 | }
12 |
13 | document.addEventListener('DOMContentLoaded', app);
14 |
--------------------------------------------------------------------------------
/public/pages/examples/components/shadow-dom/components/avatar.css:
--------------------------------------------------------------------------------
1 | x-avatar {
2 | display: flex;
3 | align-items: center;
4 | justify-content: center;
5 | width: 2.5rem;
6 | height: 2.5rem;
7 | }
8 |
9 | x-avatar[size=lg] {
10 | width: 3.5rem;
11 | height: 3.5rem;
12 | }
13 |
14 | x-avatar img {
15 | border-radius: 9999px;
16 | width: 100%;
17 | height: 100%;
18 | vertical-align: middle;
19 | object-fit: cover;
20 | }
21 |
--------------------------------------------------------------------------------
/public/pages/examples/components/shadow-dom/components/avatar.js:
--------------------------------------------------------------------------------
1 | /**
2 | * Usage:
3 | *
4 | *
5 | */
6 | class AvatarComponent extends HTMLElement {
7 | connectedCallback() {
8 | if (!this.querySelector('img')) {
9 | this.append(document.createElement('img'));
10 | }
11 | this.update();
12 | }
13 |
14 | static get observedAttributes() {
15 | return ['src', 'alt'];
16 | }
17 |
18 | attributeChangedCallback() {
19 | this.update();
20 | }
21 |
22 | update() {
23 | const img = this.querySelector('img');
24 | if (img) {
25 | img.src = this.getAttribute('src');
26 | img.alt = this.getAttribute('alt') || 'avatar';
27 | }
28 | }
29 | }
30 |
31 | export const registerAvatarComponent = () => {
32 | customElements.define('x-avatar', AvatarComponent);
33 | }
34 |
--------------------------------------------------------------------------------
/public/pages/examples/components/shadow-dom/components/badge.css:
--------------------------------------------------------------------------------
1 | x-badge {
2 | position: relative;
3 | display: inline-flex;
4 | flex-shrink: 0;
5 | box-sizing: border-box;
6 | }
7 |
8 | x-badge > span.x-badge-label {
9 | /* size and position */
10 | box-sizing: inherit;
11 | position: absolute;
12 | top: 0.2rem;
13 | right: 0.2rem;
14 | width: 1.25rem;
15 | height: 1.25rem;
16 | transform: translate(50%, -50%);
17 | z-index: 10;
18 | /* colors and fonts */
19 | color: white;
20 | background-color: rgb(0, 111, 238);
21 | border-style: solid;
22 | border-color: #333333;
23 | border-width: 2px;
24 | border-radius: 9999px;
25 | font-size: 0.875rem;
26 | line-height: 1.2;
27 | /* text placement */
28 | display: flex;
29 | place-content: center;
30 | user-select: none;
31 | }
32 |
--------------------------------------------------------------------------------
/public/pages/examples/components/shadow-dom/components/badge.js:
--------------------------------------------------------------------------------
1 | class BadgeComponent extends HTMLElement {
2 | #span;
3 |
4 | connectedCallback() {
5 | if (!this.#span) {
6 | this.#span = document.createElement('span');
7 | this.#span.className = 'x-badge-label';
8 | }
9 | this.insertBefore(this.#span, this.firstChild);
10 | this.update();
11 | }
12 |
13 | update() {
14 | if (this.#span) this.#span.textContent = this.getAttribute('content');
15 | }
16 |
17 | static get observedAttributes() {
18 | return ['content'];
19 | }
20 |
21 | attributeChangedCallback() {
22 | this.update();
23 | }
24 |
25 | set content(value) {
26 | if (this.getAttribute('content') !== value) {
27 | this.setAttribute('content', value);
28 | }
29 | }
30 |
31 | get content() {
32 | return this.getAttribute('content');
33 | }
34 | }
35 |
36 | export const registerBadgeComponent = () => customElements.define('x-badge', BadgeComponent);
37 |
--------------------------------------------------------------------------------
/public/pages/examples/components/shadow-dom/components/header.css:
--------------------------------------------------------------------------------
1 | @import "../reset.css";
2 |
3 | :host {
4 | display: block;
5 | }
6 |
7 | header {
8 | display: flex;
9 | flex-flow: row wrap;
10 | justify-content: right;
11 | align-items: center;
12 | }
13 |
14 | h1 {
15 | font-family: system-ui, sans-serif;
16 | margin: 0;
17 | display: flex;
18 | flex: 1 1 auto;
19 | }
20 |
21 | ::slotted(*) {
22 | display: flex;
23 | flex: 0 1 auto;
24 | }
25 |
--------------------------------------------------------------------------------
/public/pages/examples/components/shadow-dom/components/header.js:
--------------------------------------------------------------------------------
1 | const template = document.createElement('template');
2 | template.innerHTML = `
3 |
4 |
8 | `;
9 |
10 | class HeaderComponent extends HTMLElement {
11 | constructor() {
12 | super();
13 | if (!this.shadowRoot) {
14 | this.attachShadow({ mode: 'open' });
15 | this.shadowRoot.append(template.content.cloneNode(true));
16 | }
17 | this.update();
18 | }
19 |
20 | update() {
21 | this.shadowRoot.querySelector('h1').textContent = this.getAttribute('title');
22 | }
23 |
24 | static get observedAttributes() {
25 | return ['title'];
26 | }
27 |
28 | attributeChangedCallback() {
29 | this.update();
30 | }
31 | }
32 |
33 | export const registerHeaderComponent = () => customElements.define('x-header', HeaderComponent);
34 |
--------------------------------------------------------------------------------
/public/pages/examples/components/shadow-dom/index.css:
--------------------------------------------------------------------------------
1 | @import "./reset.css";
2 | @import "./components/avatar.css";
3 | @import "./components/badge.css";
4 |
5 | body {
6 | font-family: system-ui, sans-serif;
7 | }
8 |
9 | x-header, main {
10 | margin: 1em;
11 | padding: 1em;
12 | border: 1px dashed black;
13 | }
14 |
--------------------------------------------------------------------------------
/public/pages/examples/components/shadow-dom/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 | Hello, shadow DOM!
15 |
16 |
17 |
--------------------------------------------------------------------------------
/public/pages/examples/components/shadow-dom/index.js:
--------------------------------------------------------------------------------
1 | import { registerAvatarComponent } from './components/avatar.js';
2 | import { registerBadgeComponent } from './components/badge.js';
3 | import { registerHeaderComponent } from './components/header.js';
4 | const app = () => {
5 | registerAvatarComponent();
6 | registerBadgeComponent();
7 | registerHeaderComponent();
8 | }
9 | document.addEventListener('DOMContentLoaded', app);
10 |
--------------------------------------------------------------------------------
/public/pages/examples/components/shadow-dom/reset.css:
--------------------------------------------------------------------------------
1 | /* generic minimal CSS reset
2 | inspiration: https://www.digitalocean.com/community/tutorials/css-minimal-css-reset */
3 |
4 | :root {
5 | box-sizing: border-box;
6 | line-height: 1.4;
7 | /* https://kilianvalkhof.com/2022/css-html/your-css-reset-needs-text-size-adjust-probably/ */
8 | -moz-text-size-adjust: none;
9 | -webkit-text-size-adjust: none;
10 | text-size-adjust: none;
11 | }
12 |
13 | *, *:before, *:after {
14 | box-sizing: inherit;
15 | }
16 |
17 | body, h1, h2, h3, h4, h5, h6, p {
18 | margin: 0;
19 | padding: 0;
20 | font-weight: normal;
21 | }
22 |
23 | img {
24 | max-width:100%;
25 | height:auto;
26 | }
27 |
--------------------------------------------------------------------------------
/public/pages/examples/components/simple/hello-world.js:
--------------------------------------------------------------------------------
1 | class HelloWorldComponent extends HTMLElement {
2 | connectedCallback() {
3 | this.textContent = 'hello world!';
4 | }
5 | }
6 | customElements.define('x-hello-world', HelloWorldComponent);
7 |
--------------------------------------------------------------------------------
/public/pages/examples/components/simple/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | I just want to say...
6 |
7 |
8 |
--------------------------------------------------------------------------------
/public/pages/examples/sites/importmap/components/metrics.js:
--------------------------------------------------------------------------------
1 | import dayjs from 'dayjs';
2 | import * as webVitals from 'web-vitals';
3 |
4 | class MetricsComponent extends HTMLElement {
5 | #now = dayjs();
6 | #ttfb;
7 | #interval;
8 |
9 | connectedCallback() {
10 | webVitals.onTTFB(_ => this.#ttfb = Math.round(_.value));
11 | this.#interval = setInterval(() => this.update(), 500);
12 | }
13 |
14 | disconnectedCallback() {
15 | clearInterval(this.#interval);
16 | this.#interval = null;
17 | }
18 |
19 | update() {
20 | this.innerHTML = `
21 | Page loaded ${this.#now.fromNow()}, TTFB ${this.#ttfb} milliseconds
22 | `;
23 | }
24 | }
25 |
26 | export const registerMetricsComponent = () => {
27 | customElements.define('x-metrics', MetricsComponent);
28 | }
--------------------------------------------------------------------------------
/public/pages/examples/sites/importmap/index.css:
--------------------------------------------------------------------------------
1 | body { font-family: sans-serif; }
2 |
--------------------------------------------------------------------------------
/public/pages/examples/sites/importmap/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
15 |
16 |
17 |
18 |
19 |
20 |
--------------------------------------------------------------------------------
/public/pages/examples/sites/importmap/index.js:
--------------------------------------------------------------------------------
1 | import { registerMetricsComponent } from './components/metrics.js';
2 |
3 | const app = () => {
4 | registerMetricsComponent();
5 | };
6 |
7 | document.addEventListener('DOMContentLoaded', app);
8 |
--------------------------------------------------------------------------------
/public/pages/examples/sites/importmap/lib/dayjs/module.js:
--------------------------------------------------------------------------------
1 | // UMD version of dayjs, from https://unpkg.com/dayjs/
2 | const dayjs = window.dayjs;
3 | const dayjsRelativeTime = window.dayjs_plugin_relativeTime;
4 | dayjs.extend(dayjsRelativeTime);
5 |
6 | export default dayjs;
7 |
--------------------------------------------------------------------------------
/public/pages/examples/sites/importmap/lib/dayjs/relativeTime.js:
--------------------------------------------------------------------------------
1 | !function(r,e){"object"==typeof exports&&"undefined"!=typeof module?module.exports=e():"function"==typeof define&&define.amd?define(e):(r="undefined"!=typeof globalThis?globalThis:r||self).dayjs_plugin_relativeTime=e()}(this,(function(){"use strict";return function(r,e,t){r=r||{};var n=e.prototype,o={future:"in %s",past:"%s ago",s:"a few seconds",m:"a minute",mm:"%d minutes",h:"an hour",hh:"%d hours",d:"a day",dd:"%d days",M:"a month",MM:"%d months",y:"a year",yy:"%d years"};function i(r,e,t,o){return n.fromToBase(r,e,t,o)}t.en.relativeTime=o,n.fromToBase=function(e,n,i,d,u){for(var f,a,s,l=i.$locale().relativeTime||o,h=r.thresholds||[{l:"s",r:44,d:"second"},{l:"m",r:89},{l:"mm",r:44,d:"minute"},{l:"h",r:89},{l:"hh",r:21,d:"hour"},{l:"d",r:35},{l:"dd",r:25,d:"day"},{l:"M",r:45},{l:"MM",r:10,d:"month"},{l:"y",r:17},{l:"yy",d:"year"}],m=h.length,c=0;c0,p<=y.r||!y.r){p<=1&&c>0&&(y=h[c-1]);var v=l[y.l];u&&(p=u(""+p)),a="string"==typeof v?v.replace("%d",p):v(p,n,y.l,s);break}}if(n)return a;var M=s?l.future:l.past;return"function"==typeof M?M(a):M.replace("%s",a)},n.to=function(r,e){return i(r,e,this,!0)},n.from=function(r,e){return i(r,e,this)};var d=function(r){return r.$u?t.utc():t()};n.toNow=function(r){return this.to(d(this),r)},n.fromNow=function(r){return this.from(d(this),r)}}}));
--------------------------------------------------------------------------------
/public/pages/examples/sites/imports/components/metrics.js:
--------------------------------------------------------------------------------
1 | import { dayjs, webVitals } from '../lib/imports.js';
2 |
3 | class MetricsComponent extends HTMLElement {
4 | #now = dayjs();
5 | #ttfb;
6 | #interval;
7 |
8 | connectedCallback() {
9 | webVitals.onTTFB(_ => this.#ttfb = Math.round(_.value));
10 | this.#interval = setInterval(() => this.update(), 500);
11 | }
12 |
13 | disconnectedCallback() {
14 | clearInterval(this.#interval);
15 | this.#interval = null;
16 | }
17 |
18 | update() {
19 | this.innerHTML = `
20 | Page loaded ${this.#now.fromNow()}, TTFB ${this.#ttfb} milliseconds
21 | `;
22 | }
23 | }
24 |
25 | export const registerMetricsComponent = () => {
26 | customElements.define('x-metrics', MetricsComponent);
27 | }
--------------------------------------------------------------------------------
/public/pages/examples/sites/imports/index.css:
--------------------------------------------------------------------------------
1 | body { font-family: sans-serif; }
2 |
--------------------------------------------------------------------------------
/public/pages/examples/sites/imports/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
--------------------------------------------------------------------------------
/public/pages/examples/sites/imports/index.js:
--------------------------------------------------------------------------------
1 | import { registerMetricsComponent } from './components/metrics.js';
2 |
3 | const app = () => {
4 | registerMetricsComponent();
5 | };
6 |
7 | document.addEventListener('DOMContentLoaded', app);
8 |
--------------------------------------------------------------------------------
/public/pages/examples/sites/imports/lib/dayjs/relativeTime.js:
--------------------------------------------------------------------------------
1 | !function(r,e){"object"==typeof exports&&"undefined"!=typeof module?module.exports=e():"function"==typeof define&&define.amd?define(e):(r="undefined"!=typeof globalThis?globalThis:r||self).dayjs_plugin_relativeTime=e()}(this,(function(){"use strict";return function(r,e,t){r=r||{};var n=e.prototype,o={future:"in %s",past:"%s ago",s:"a few seconds",m:"a minute",mm:"%d minutes",h:"an hour",hh:"%d hours",d:"a day",dd:"%d days",M:"a month",MM:"%d months",y:"a year",yy:"%d years"};function i(r,e,t,o){return n.fromToBase(r,e,t,o)}t.en.relativeTime=o,n.fromToBase=function(e,n,i,d,u){for(var f,a,s,l=i.$locale().relativeTime||o,h=r.thresholds||[{l:"s",r:44,d:"second"},{l:"m",r:89},{l:"mm",r:44,d:"minute"},{l:"h",r:89},{l:"hh",r:21,d:"hour"},{l:"d",r:35},{l:"dd",r:25,d:"day"},{l:"M",r:45},{l:"MM",r:10,d:"month"},{l:"y",r:17},{l:"yy",d:"year"}],m=h.length,c=0;c0,p<=y.r||!y.r){p<=1&&c>0&&(y=h[c-1]);var v=l[y.l];u&&(p=u(""+p)),a="string"==typeof v?v.replace("%d",p):v(p,n,y.l,s);break}}if(n)return a;var M=s?l.future:l.past;return"function"==typeof M?M(a):M.replace("%s",a)},n.to=function(r,e){return i(r,e,this,!0)},n.from=function(r,e){return i(r,e,this)};var d=function(r){return r.$u?t.utc():t()};n.toNow=function(r){return this.to(d(this),r)},n.fromNow=function(r){return this.from(d(this),r)}}}));
--------------------------------------------------------------------------------
/public/pages/examples/sites/imports/lib/imports.js:
--------------------------------------------------------------------------------
1 | // UMD version of dayjs, from https://unpkg.com/dayjs/
2 | const dayjs = window.dayjs;
3 | const dayjsRelativeTime = window.dayjs_plugin_relativeTime;
4 | dayjs.extend(dayjsRelativeTime);
5 |
6 | // ESM version of web-vitals, from https://unpkg.com/web-vitals/dist/web-vitals.js
7 | import * as webVitals from './web-vitals.js';
8 |
9 | export { dayjs, webVitals };
10 |
--------------------------------------------------------------------------------
/public/pages/examples/sites/page/example.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | Example
5 |
6 |
7 |
8 |
9 |
10 |
11 | Please enable JavaScript to view this page correctly.
12 |
13 | title and navigation ...
14 |
15 |
16 | main content ...
17 |
18 |
19 | byline and copyright ...
20 |
21 |
22 |
--------------------------------------------------------------------------------
/public/pages/examples/sites/page/example2.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | Example
5 |
6 |
7 |
8 |
9 |
10 |
11 | Please enable JavaScript to view this page.
12 |
13 |
14 | title and navigation ...
15 |
16 |
17 | main content ...
18 |
19 |
20 | byline and copyright ...
21 |
22 |
23 |
24 |
--------------------------------------------------------------------------------
/public/pages/examples/sites/page/index.js:
--------------------------------------------------------------------------------
1 | const app = () => {
2 | const template = document.querySelector('template#page');
3 | if (template) document.body.appendChild(template.content, true);
4 | }
5 |
6 | document.addEventListener('DOMContentLoaded', app);
7 |
--------------------------------------------------------------------------------
/public/pages/examples/styling/replacing-css-modules/nextjs/layout.tsx:
--------------------------------------------------------------------------------
1 | import styles from './styles.module.css'
2 |
3 | export default function DashboardLayout({
4 | children,
5 | }: {
6 | children: React.ReactNode
7 | }) {
8 | return
9 | }
--------------------------------------------------------------------------------
/public/pages/examples/styling/replacing-css-modules/nextjs/styles.module.css:
--------------------------------------------------------------------------------
1 | .dashboard {
2 | padding: 24px;
3 | }
--------------------------------------------------------------------------------
/public/pages/examples/styling/replacing-css-modules/vanilla/layout.js:
--------------------------------------------------------------------------------
1 | class Layout extends HTMLElement {
2 | constructor() {
3 | super();
4 | this.attachShadow({ mode: 'open' });
5 | this.shadowRoot.innerHTML = `
6 |
7 |
8 | `;
9 | }
10 | }
11 |
12 | export const registerLayoutComponent =
13 | () => customElements.define('x-layout', Layout);
14 |
--------------------------------------------------------------------------------
/public/pages/examples/styling/replacing-css-modules/vanilla/styles.css:
--------------------------------------------------------------------------------
1 | @import "../shared.css";
2 |
3 | .dashboard {
4 | padding: 24px;
5 | }
--------------------------------------------------------------------------------
/public/pages/examples/styling/scoping-prefixed/components/example/example.css:
--------------------------------------------------------------------------------
1 | x-example p {
2 | font-family: casual, cursive;
3 | color: darkblue;
4 | }
--------------------------------------------------------------------------------
/public/pages/examples/styling/scoping-prefixed/components/example/example.js:
--------------------------------------------------------------------------------
1 | class ExampleComponent extends HTMLElement {
2 | connectedCallback() {
3 | this.innerHTML = 'For example...
';
4 | }
5 | }
6 | export const registerExampleComponent = () => {
7 | customElements.define('x-example', ExampleComponent);
8 | }
9 |
--------------------------------------------------------------------------------
/public/pages/examples/styling/scoping-prefixed/components/example/example_nested.css:
--------------------------------------------------------------------------------
1 | x-example {
2 | p {
3 | font-family: casual, cursive;
4 | color: darkblue;
5 | }
6 | }
--------------------------------------------------------------------------------
/public/pages/examples/styling/scoping-prefixed/index.css:
--------------------------------------------------------------------------------
1 | @import "./components/example/example.css";
2 |
--------------------------------------------------------------------------------
/public/pages/examples/styling/scoping-prefixed/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 | This <p> is not affected, because it is outside the custom element.
10 |
11 |
--------------------------------------------------------------------------------
/public/pages/examples/styling/scoping-prefixed/index.js:
--------------------------------------------------------------------------------
1 | import { registerExampleComponent } from './components/example/example.js';
2 | const app = () => {
3 | registerExampleComponent();
4 | }
5 | document.addEventListener('DOMContentLoaded', app);
6 |
--------------------------------------------------------------------------------
/public/pages/examples/styling/scoping-shadowed/components/example/example.css:
--------------------------------------------------------------------------------
1 | p {
2 | font-family: casual, cursive;
3 | color: darkblue;
4 | }
--------------------------------------------------------------------------------
/public/pages/examples/styling/scoping-shadowed/components/example/example.js:
--------------------------------------------------------------------------------
1 | class ExampleComponent extends HTMLElement {
2 | constructor() {
3 | super();
4 | this.attachShadow({mode: 'open'});
5 | this.shadowRoot.innerHTML = `
6 |
7 | For example...
8 |
9 | `;
10 | }
11 | }
12 | export const registerExampleComponent = () => {
13 | customElements.define('x-example', ExampleComponent);
14 | }
15 |
--------------------------------------------------------------------------------
/public/pages/examples/styling/scoping-shadowed/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 | This <p> is not affected, even though it is slotted.
9 |
10 |
11 |
12 |
--------------------------------------------------------------------------------
/public/pages/examples/styling/scoping-shadowed/index.js:
--------------------------------------------------------------------------------
1 | import { registerExampleComponent } from './components/example/example.js';
2 | const app = () => {
3 | registerExampleComponent();
4 | }
5 | document.addEventListener('DOMContentLoaded', app);
6 |
--------------------------------------------------------------------------------
/public/robots.txt:
--------------------------------------------------------------------------------
1 | User-agent: *
2 | Disallow: /blog/generator.html
3 | Disallow: /pages/examples/
4 |
--------------------------------------------------------------------------------
/public/sitemap.txt:
--------------------------------------------------------------------------------
1 | https://plainvanillaweb.com/
2 | https://plainvanillaweb.com/index.html
3 | https://plainvanillaweb.com/pages/components.html
4 | https://plainvanillaweb.com/pages/styling.html
5 | https://plainvanillaweb.com/pages/sites.html
6 | https://plainvanillaweb.com/pages/applications.html
7 | https://plainvanillaweb.com/blog/index.html
8 | https://plainvanillaweb.com/blog/archive.html
9 | https://plainvanillaweb.com/blog/articles/2024-08-17-lets-build-a-blog/index.html
10 | https://plainvanillaweb.com/blog/articles/2024-08-25-vanilla-entity-encoding/index.html
11 | https://plainvanillaweb.com/blog/articles/2024-08-30-poor-mans-signals/index.html
12 | https://plainvanillaweb.com/blog/articles/2024-09-03-unix-philosophy/index.html
13 | https://plainvanillaweb.com/blog/articles/2024-09-06-how-fast-are-web-components/index.html
14 |
--------------------------------------------------------------------------------
/public/styles/reset.css:
--------------------------------------------------------------------------------
1 | /* generic minimal CSS reset
2 | inspiration: https://www.digitalocean.com/community/tutorials/css-minimal-css-reset */
3 |
4 | :root {
5 | box-sizing: border-box;
6 | line-height: 1.4;
7 | /* https://kilianvalkhof.com/2022/css-html/your-css-reset-needs-text-size-adjust-probably/ */
8 | -moz-text-size-adjust: none;
9 | -webkit-text-size-adjust: none;
10 | text-size-adjust: none;
11 | }
12 |
13 | *, *::before, *::after {
14 | box-sizing: inherit;
15 | }
16 |
17 | body, h1, h2, h3, h4, h5, h6, p {
18 | margin: 0;
19 | padding: 0;
20 | font-weight: normal;
21 | }
22 |
23 | img {
24 | max-width:100%;
25 | height:auto;
26 | }
27 |
--------------------------------------------------------------------------------
/public/styles/variables.css:
--------------------------------------------------------------------------------
1 | :root {
2 | /* https://modernfontstacks.com/
3 | geometric humanist font */
4 | --font-system: Avenir, Montserrat, Corbel, source-sans-pro, sans-serif;
5 | /* monospace code font */
6 | --font-system-code: ui-monospace, 'Cascadia Code', 'Source Code Pro', Menlo, Consolas, 'DejaVu Sans Mono', monospace;
7 | --font-system-code-size: 0.8rem;
8 |
9 | --background-color: white;
10 |
11 | --text-color: black;
12 | --text-color-mute: hsl(0, 0%, 40%);
13 |
14 | --link-color: darkblue;
15 |
16 | --nav-separator-color: goldenrod;
17 | --nav-background-color: hsl(50, 50%, 95%);
18 |
19 | --border-color: black;
20 |
21 | --code-text-color: var(--text-color);
22 | --code-text-color-bg: inherit;
23 |
24 | --panel-title-color: black;
25 | --panel-title-color-bg: cornsilk;
26 | }
27 |
--------------------------------------------------------------------------------
/public/tests/imports-test.js:
--------------------------------------------------------------------------------
1 | const { expect } = window.chai;
2 | const { getByText, queries, within, waitFor, fireEvent } = window.TestingLibraryDom;
3 |
4 | let rootContainer;
5 | let screen;
6 |
7 | beforeEach(() => {
8 | // the hidden div where the test can render elements
9 | rootContainer = document.createElement("div");
10 | rootContainer.style.position = 'absolute';
11 | rootContainer.style.left = '-10000px';
12 | document.body.appendChild(rootContainer);
13 | // pre-bind @testing-library/dom helpers to rootContainer
14 | screen = Object.keys(queries).reduce((helpers, key) => {
15 | const fn = queries[key]
16 | helpers[key] = fn.bind(null, rootContainer)
17 | return helpers
18 | }, {});
19 | });
20 |
21 | afterEach(() => {
22 | document.body.removeChild(rootContainer);
23 | rootContainer = null;
24 | });
25 |
26 | function render(el) {
27 | rootContainer.appendChild(el);
28 | }
29 |
30 | export {
31 | rootContainer,
32 | expect,
33 | render,
34 | getByText, screen, within, waitFor, fireEvent
35 | };
36 |
--------------------------------------------------------------------------------
/public/tests/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | Plain Vanilla - Tests
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
31 |
32 |
--------------------------------------------------------------------------------
/public/tests/index.js:
--------------------------------------------------------------------------------
1 | import { registerTabPanelComponent } from "../components/tab-panel/tab-panel.js";
2 |
3 | const app = () => {
4 | registerTabPanelComponent();
5 | mocha.run();
6 | }
7 |
8 | document.addEventListener('DOMContentLoaded', app);
9 |
--------------------------------------------------------------------------------
/public/tests/tabpanel.test.js:
--------------------------------------------------------------------------------
1 | import { render, screen, waitFor, expect, fireEvent } from './imports-test.js';
2 |
3 | const renderTabPanel = () => {
4 | const div = document.createElement('div');
5 | div.innerHTML = `
6 |
7 |
8 | Tab 1 content
9 |
10 |
11 | Tab 2 content
12 |
13 |
14 | `;
15 | render(div);
16 | }
17 |
18 | describe('tabpanel', () => {
19 | it("renders a tabpanel with active tab", async () => {
20 | // ARRANGE
21 | renderTabPanel();
22 |
23 | // ASSERT
24 | // active tab is selected
25 | const activeTab = await screen.findByRole('tab', { name: 'Tab 1', selected: true });
26 | expect(activeTab).to.not.be.undefined;
27 | // active tabpanel is visible
28 | const activePanel = screen.getByText(/Tab 1 content/);
29 | expect(activePanel).to.not.be.undefined;
30 | // not active tabpanel content is hidden
31 | const tab2 = screen.getByTitle('Tab 2');
32 | await waitFor(() => expect(tab2.offsetParent).to.be.null);
33 | });
34 |
35 | it("activates a different tab on click", async () => {
36 | // ARRANGE
37 | renderTabPanel();
38 | const tab2 = screen.getByTitle('Tab 2');
39 |
40 | // ASSERT
41 | // inactive tabpanel content is hidden
42 | await waitFor(() => expect(tab2.offsetParent).to.be.null);
43 | // find inactive tab button and click it
44 | const tab2Button = await screen.findByRole('tab', { name: 'Tab 2' });
45 | expect(tab2Button).not.to.be.undefined;
46 | fireEvent.click(tab2Button);
47 | // inactive tabpanel content is made visible
48 | await waitFor(() => expect(tab2.offsetParent).not.to.be.null);
49 | });
50 | });
51 |
--------------------------------------------------------------------------------