├── .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 | 14 |
15 |

Plain Vanilla Blog

16 | 23 |
24 |
25 |

Archive

26 | 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 | Another AI image 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 = ``; 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 = ``; 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``; 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 | 21 |
22 |
23 | 24 | 25 |
26 |
27 | 28 | 29 |
30 | 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 | 9 | 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 | 20 | 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 | 22 | 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 | 14 | 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 | 15 | 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 | 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 | 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 | 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 | 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 | 38 | 39 | ); 40 | } else { 41 | taskContent = ( 42 | <> 43 | {task.text} 44 | 47 | 48 | ); 49 | } 50 | return ( 51 | 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 | 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 = ''; 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
{children}
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 | 14 | 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 | 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 | 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 |
2 |

3 | My favorite colors are 4 | and . 5 |

6 | 7 |
-------------------------------------------------------------------------------- /public/blog/articles/2025-05-09-form-control/demo1/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | demo 1 7 | 8 | 16 | 17 | 18 |
19 |

20 | My favorite colors are 21 | and . 22 |

23 | 24 | 25 |
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 |
19 |

20 | My favorite colors are 21 | and . 22 |

23 | 24 | 25 |
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 |
19 |

20 | My favorite colors are 21 | and . 22 |

23 | 24 | 25 |
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 |
2 |

3 | My favorite color is . 4 |

5 | 6 | 7 |
-------------------------------------------------------------------------------- /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 |
23 |

24 | My favorite color is . 25 |

26 | 27 | 28 |
29 |

30 | 31 | 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 = ''; 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 | 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 | 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 |
15 |

Plain Vanilla Blog

16 |

A blog about vanilla web development — no frameworks, just standards.

17 | 23 |
24 |
25 |

Featured

26 |
    27 |
  • 28 | 29 |

    Making a new form control

    30 |

    Building a form control as a custom element.

    31 | 32 | 33 | 34 |
  • 35 |
36 | 37 |

Latest Posts

38 | 39 | 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'), 27 | sub: xmlElement.sub 28 | }, 29 | { 30 | match: RegExp(`${xmlElement.match}|[^]*(?=$)`, 'g'), 31 | sub: 'css' 32 | }, 33 | xmlElement 34 | ] 35 | }, 36 | { 37 | match: RegExp(`((?!)[^])*`, 'g'), 38 | sub: [ 39 | { 40 | match: RegExp(`^`, 'g'), 41 | sub: xmlElement.sub 42 | }, 43 | { 44 | match: RegExp(`${xmlElement.match}|[^]*(?=$)`, '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 | 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 |
10 |

11 | 12 | 13 |
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 | 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 | 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 |
16 |

17 | 18 |
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 | 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 | 10 | 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 |
6 | 7 | 8 | 9 | 10 | 11 |
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 |
5 |

6 | 7 |
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 | 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 | 12 | 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
{children}
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 | --------------------------------------------------------------------------------