Repository: jsebrech/plainvanilla Branch: main Commit: 25e704acad1e Files: 352 Total size: 2.6 MB Directory structure: gitextract_5pqw1n3f/ ├── .github/ │ └── workflows/ │ └── static.yml ├── .gitignore ├── .hintrc ├── LICENSE.md ├── README.md ├── eslint.config.cjs └── public/ ├── blog/ │ ├── archive.html │ ├── articles/ │ │ ├── 2024-08-17-lets-build-a-blog/ │ │ │ ├── card.html │ │ │ ├── example.html │ │ │ ├── generator.js │ │ │ └── index.html │ │ ├── 2024-08-25-vanilla-entity-encoding/ │ │ │ ├── example1.js │ │ │ ├── example2.js │ │ │ ├── example3.js │ │ │ ├── html.js │ │ │ └── index.html │ │ ├── 2024-08-30-poor-mans-signals/ │ │ │ ├── adder.html │ │ │ ├── adder.js │ │ │ ├── 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 │ │ │ └── index.html │ │ ├── 2024-09-06-how-fast-are-web-components/ │ │ │ └── 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 │ │ │ ├── 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 │ │ │ ├── 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 │ │ │ ├── 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/ │ │ │ └── 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 │ │ │ ├── index.html │ │ │ ├── theme-context-fragment.html │ │ │ ├── theme-context.js │ │ │ └── theme-provider.js │ │ ├── 2024-10-20-editing-plain-vanilla/ │ │ │ ├── .hintrc │ │ │ ├── eslint.config.cjs │ │ │ └── index.html │ │ ├── 2024-12-16-caching-vanilla-sites/ │ │ │ ├── index.html │ │ │ └── sw.js │ │ ├── 2025-01-01-new-years-resolve/ │ │ │ ├── example-index.js │ │ │ ├── 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 │ │ │ └── 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 │ │ │ └── index.html │ │ ├── 2025-06-12-view-transitions/ │ │ │ ├── example1/ │ │ │ │ ├── index.html │ │ │ │ ├── index.js │ │ │ │ └── transitions.css │ │ │ ├── example2/ │ │ │ │ ├── index.html │ │ │ │ ├── index.js │ │ │ │ └── transitions.css │ │ │ ├── example3/ │ │ │ │ ├── index.html │ │ │ │ ├── index.js │ │ │ │ ├── transitions.css │ │ │ │ └── view-transition.js │ │ │ ├── example4/ │ │ │ │ ├── index.html │ │ │ │ ├── index.js │ │ │ │ ├── transitions.css │ │ │ │ └── view-transition.js │ │ │ ├── example5/ │ │ │ │ ├── index.html │ │ │ │ ├── index.js │ │ │ │ ├── transitions.css │ │ │ │ ├── view-transition-part.js │ │ │ │ └── view-transition.js │ │ │ ├── example6/ │ │ │ │ ├── index.html │ │ │ │ ├── lib/ │ │ │ │ │ ├── html.js │ │ │ │ │ ├── view-route.js │ │ │ │ │ └── view-transition.js │ │ │ │ └── src/ │ │ │ │ ├── App.js │ │ │ │ ├── Details.js │ │ │ │ ├── Home.js │ │ │ │ ├── Icons.js │ │ │ │ ├── Layout.js │ │ │ │ ├── LikeButton.js │ │ │ │ ├── Videos.js │ │ │ │ ├── animations.css │ │ │ │ ├── data.js │ │ │ │ ├── index.js │ │ │ │ └── styles.css │ │ │ └── index.html │ │ ├── 2025-06-25-routing/ │ │ │ ├── example1/ │ │ │ │ ├── app.js │ │ │ │ ├── index.html │ │ │ │ └── view-route.js │ │ │ ├── example2/ │ │ │ │ ├── app.js │ │ │ │ ├── index.html │ │ │ │ ├── view-route-partial.js │ │ │ │ ├── view-route-partial2.js │ │ │ │ └── view-route.js │ │ │ ├── example3/ │ │ │ │ ├── app.js │ │ │ │ ├── index.html │ │ │ │ ├── view-route-partial.js │ │ │ │ └── view-route.js │ │ │ ├── example4/ │ │ │ │ ├── 404.html │ │ │ │ └── index-partial.html │ │ │ └── index.html │ │ ├── 2025-07-13-history-architecture/ │ │ │ └── index.html │ │ ├── 2025-07-16-local-first-architecture/ │ │ │ ├── example1.html │ │ │ └── index.html │ │ ├── 2026-03-01-redesigning-plain-vanilla/ │ │ │ ├── index.html │ │ │ └── nav-menu.html │ │ ├── 2026-03-09-details-matters/ │ │ │ ├── example1/ │ │ │ │ ├── index-partial.html │ │ │ │ └── index.html │ │ │ ├── example2/ │ │ │ │ ├── index-partial.html │ │ │ │ └── index.html │ │ │ ├── example3/ │ │ │ │ ├── index-partial.html │ │ │ │ └── index.html │ │ │ ├── example4/ │ │ │ │ ├── index-partial.html │ │ │ │ └── index.html │ │ │ ├── example5/ │ │ │ │ ├── index-partial.html │ │ │ │ └── index.html │ │ │ └── index.html │ │ └── index.json │ ├── components/ │ │ ├── blog-archive.js │ │ ├── blog-footer.js │ │ ├── blog-header.js │ │ └── blog-latest-posts.js │ ├── example-base.css │ ├── 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 ├── index.css ├── index.html ├── index.js ├── lib/ │ ├── 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/ │ │ │ │ │ ├── module.js │ │ │ │ │ └── relativeTime.js │ │ │ │ └── web-vitals.js │ │ │ ├── imports/ │ │ │ │ ├── components/ │ │ │ │ │ └── metrics.js │ │ │ │ ├── index.css │ │ │ │ ├── index.html │ │ │ │ ├── index.js │ │ │ │ └── lib/ │ │ │ │ ├── dayjs/ │ │ │ │ │ └── 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 ================================================ FILE CONTENTS ================================================ ================================================ FILE: .github/workflows/static.yml ================================================ # Simple workflow for deploying static content to GitHub Pages name: Deploy static content to Pages on: # Runs on pushes targeting the default branch push: branches: ["main"] # Allows you to run this workflow manually from the Actions tab workflow_dispatch: # Sets permissions of the GITHUB_TOKEN to allow deployment to GitHub Pages permissions: contents: read pages: write id-token: write # Allow only one concurrent deployment, skipping runs queued between the run in-progress and latest queued. # However, do NOT cancel in-progress runs as we want to allow these production deployments to complete. concurrency: group: "pages" cancel-in-progress: false jobs: # Single deploy job since we're just deploying deploy: environment: name: github-pages url: ${{ steps.deployment.outputs.page_url }} runs-on: ubuntu-latest steps: - name: Checkout uses: actions/checkout@v4 - name: Setup Pages uses: actions/configure-pages@v5 - name: Create analytics file run: echo "${{ vars.CLOUDFLARE_ANALYTICS }}" > public/analytics.template - name: Upload artifact uses: actions/upload-pages-artifact@v3 with: # Deploy public folder only path: './public' - name: Deploy to GitHub Pages id: deployment uses: actions/deploy-pages@v4 ================================================ FILE: .gitignore ================================================ .DS_Store *.pem public/analytics.template ================================================ FILE: .hintrc ================================================ { "extends": [ "development" ], "hints": { "compat-api/html": [ "default", { "ignore": [ "iframe[loading]", "nav[popover]", "button[popovertarget]", "button[popovertargetaction]" ] } ], "no-inline-styles": "off", "compat-api/css": [ "default", { "ignore": [ "view-transition-name", "text-size-adjust", "text-size-adjust: none", "overscroll-behavior", "overscroll-behavior: none" ] } ] } } ================================================ FILE: LICENSE.md ================================================ MIT License Copyright (c) 2024 Joeri Sebrechts 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: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 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. ================================================ FILE: README.md ================================================ # Plain Vanilla 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. ## Running Run the `public/` folder as a static website: - node: `npx http-server public -c-1` - php: `php -S localhost:8000 -t public` - python: `python3 -m http.server 8000 --directory public` Or use the VS Code Live Preview extension to show `public/index.html`. ## Contributing Issues or PR's welcome! ## Other resources These are some other resources demonstrating #notools web development techniques. - [MDN Learn Web Development](https://developer.mozilla.org/en-US/docs/Learn): a vanilla web development learning path - [Odin Project Foundations](https://www.theodinproject.com/paths/foundations/courses/foundations): a vanilla web development course - [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 - [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 - [plainJS](https://plainjs.com/): a collection of vanilla javascript functions and plugins to replace the use of jQuery - [Web Dev Toolkit](https://gomakethings.com/toolkit/): a collection of vanilla helper functions, boilerplates and libraries - [The Modern JavaScipt Tutorial](https://javascript.info/): a step-by-step tutorial to learn vanilla JavaScript ## Attribution Soft ice icon by [Twemoji](https://github.com/jdecked/twemoji) is licensed under [CC BY 4.0](http://creativecommons.org/licenses/by/4.0/). ================================================ FILE: eslint.config.cjs ================================================ /* eslint-disable no-undef */ const globals = require("globals"); const js = require("@eslint/js"); module.exports = [ js.configs.recommended, { languageOptions: { globals: { ...globals.browser, ...globals.mocha }, ecmaVersion: 2022, sourceType: "module", } }, { ignores: [ "public/blog/articles/", "**/lib/", "**/react/", ] } ]; ================================================ FILE: public/blog/archive.html ================================================ Plain Vanilla Blog

Plain Vanilla Blog

Archive

================================================ FILE: public/blog/articles/2024-08-17-lets-build-a-blog/card.html ================================================ ================================================ FILE: public/blog/articles/2024-08-17-lets-build-a-blog/example.html ================================================ A spiffy title! Another AI image

A spiffy title!

Malkovich

Article text goes here ...
================================================ FILE: public/blog/articles/2024-08-17-lets-build-a-blog/generator.js ================================================ customElements.define('blog-generator', class BlogGenerator extends HTMLElement { // ... async processArticle(article, path) { const file = await article.getFile(); const html = await file.text(); const dom = (new DOMParser()).parseFromString(html, 'text/html'); // mandatory const title = dom.querySelector('title').textContent; const summary = dom.querySelector('meta[name="description"]').getAttribute('content'); const published = dom.querySelector('blog-header').getAttribute('published'); const content = await this.processArticleContent(dom.querySelector('main'), path); const slug = path.name; // optional const img = dom.querySelector('blog-header img'); const image = img && { src: img.getAttribute('src'), alt: img.getAttribute('alt') }; const updated = dom.querySelector('blog-header').getAttribute('updated') || undefined; this.#articles.push({ slug, title, summary, content, published, updated, image }); } async processArticleContent(main, path) { // inline code examples await Promise.all([...main.querySelectorAll('x-code-viewer')].map(async (elem) => { const text = await this.downloadFile(elem.getAttribute('src'), path); const pre = document.createElement('pre'); pre.innerHTML = html`${text}`; elem.replaceWith(pre); })); // convert img src to absolute url [...main.querySelectorAll('img')].map((elem) => { const src = elem.getAttribute('src'); if (src.indexOf('http') !== 0) { elem.setAttribute('src', new URL(`articles/${path.name}/${src}`, BLOG_BASE_URL)); } }); // replace iframes by links [...main.querySelectorAll('iframe')].map((elem) => { const src = elem.getAttribute('src'); const title = elem.getAttribute('title') || src; const a = document.createElement('a'); a.textContent = title; const p = document.createElement('p'); p.appendChild(a); elem.replaceWith(p); if (src.indexOf('http') !== 0) { a.href = new URL(`articles/${path.name}/${src}`, BLOG_BASE_URL); } else { a.href = src; } }); return main.innerHTML; } // ... }); ================================================ FILE: public/blog/articles/2024-08-17-lets-build-a-blog/index.html ================================================ Let's build a blog, vanilla-style! Bricks being laid by hand

Let's build a blog, vanilla-style!

Joeri Sebrechts

As I write this paragraph it is my birthday, and it seemed like as good an opportunity as any to start a blog about vanilla web development. This blog post will be a bit unusual, as I will be writing it while I'm making the blog's inner workings. But before I get around to figuring out and then explaining how it was made, let me start with why.

Origin story

I have been building web sites since the late 90's, and over the years there were always two constants: (1) browsers were terrible developer platforms, (2) new tools and frameworks built ever taller on top of them. The tools were necessary, but their growing complexity frustrated me, and in that frustration lies the origin of this blog.

A few years ago something unexpected happened: Microsoft moved away from their (underfeatured) Trident browser engine. Suddenly there was a new baseline of browsers, a capable baseline. Browsers got good! I explored what modern browsers could do as a developer platform, and grew excited with the possibilities to treat the browser itself as the framework, without a middleman. That eventually led into making the Plain Vanilla website, a framework tutorial for the web standards platform.

In building this website editorial choices had to be made. Trying to explain too much would only confuse people, so the tutorial was trimmed of its fat. There is however so much more to explore, and that is where this blog enters the picture. Here I will talk about some of the things that didn't find a home in the Plain Vanilla tutorial, and document the new things that do.

What is a blog anyway?

Of course, a blog about vanilla web development has to be built vanilla-style. That means no build steps, no server-side logic, no frameworks or libraries. Bottom line that means throwing up a bunch of HTML pages with an index page linking them together, but that by itself isn't enough. The idea is to make a full-fat modern feeling blog, something that has what people expect a blog to have. So off I went to look at popular blogs and build a list of features.

A modern blog will have ...

The challenge was: how to do all of that within the vanilla constraints that I set myself?

Article-first design

The core of the blog experience is the article, so getting that right is key and that makes it the best place to start. Lacking any kind of generator or server-side routing, each article has to be written as a discrete html page in order to be discoverable by Google. Authoring those html pages should be straightforward, with minimal boilerplate.

After careful consideration we present to you, an article page blueprint...

This does several things for me. It keeps the <head> section as minimal as possible. It also moves the navigation at the top and bottom into dedicated web components. The header component accepts the article's image and title as child elements, neatly leaving the main element containing just the article's content and nothing else, making it easy to extract (but more on that later).

When users have scripting disabled they won't get the header navigation, but thanks to this CSS they do get a warning:
@media (scripting: none) { blog-header::before { content: ' ... ' } }
This approach frees me from thinking about noscript warnings while writing an article.

Finally, for comments I considered Disqus, but didn't want to include their embed. So instead the footer accepts the URL to a mastodon toot about the article, and will automatically generate a link that goes there. Given that the blog has a technical audience I'm pretty sure they can figure out how to reply over there. This approach can be extended to show replies inline on the page by calling the Mastodon API, but I didn't tackle that yet. It's somewhat cumbersome to first post an article, then toot about it, and then update the article with the toot's URL, but I'll survive. Incidentally, I still giggle like a schoolgirl inside my head every time I type the word toot.

Organizing files

Next comes the question how to organize the article files into a coherent structure. After thinking it over, this is what I landed on:

By wrapping every article and all its resources into a folder, each article can get as messy and complicated as it wants. The shared index.js and index.css is separate from that of the main site to keep the blog's resources out of the Plain Vanilla site's pages, and vice versa.

Building indexes

You wouldn't think a blog has a need for many indexes, but in fact this modest blog will have three:

  1. The recent posts section on the main landing page
  2. The recent posts in the RSS feed
  3. The full list of articles in the archive page

Visually showing an index is not so difficult, as a web component built around a simple <li>-based card design can be used to show both the recent posts and the archive page, and was straighforward to style with CSS.

Getting that data in a convenient form however is another matter. The RSS feed contains full text contents, so needs a separate step to build from the articles' HTML. The recent posts section on the index page thankfully can be built by reading the RSS feed, so was trivial to solve with a <blog-latest-posts> web component once the feed was set up. The full list of articles however cannot, as the RSS feed would grow too large if it contained all posts. So the archive page needs another separate step to build the full list of links from the folder of articles.

For these build steps I considered various options:

Manually keeping the files in sync
It sounded like a lot of work, and error-prone, so a hard no on that one.
A generator script, and a package.json
This is what I would normally go for, relying on a bunch of npm dependencies and a bunch of scripting to process the articles into the index files that are needed. It felt like cheating to bring in node and its ecosystem, so again this was a no.
✅ A separate generator webpage
I've wanted to play around with the File System API for a while, and this seemed a good opportunity. Turning the generator into a webpage also leaves options for actually running the web components and extracting their dynamically rendered content.

For the generator page I built a dedicated web component that allows opening or dropping the local blog/ folder with the newly written or updated articles, and then will process those into a feed.xml and index.json. The JSON file is used to load the contents of the archive page. The RSS feed is particularly tricky, because there is a limited set of tags that it should contain. By loading the article's HTML into a DOM parser, and replacing all unsupported tags (like the code viewer web component shown below) the HTML can be transformed into something that can be used as RSS feed entry contents.

The core logic of the generator extracts the article's metadata and transforms the HTML:

To give you an idea of what generator.html looks like in use:

generator page screenshot

The generator JS ended up around 250 lines of code, so not too cumbersome to build or maintain. If you're curious about the generator, check out the blog's code on Github. It can be found in generator.html and generator.js.

The user experience of writing a blog post then boils down to this:

  1. Create an article folder and write the article as HTML
  2. Open the generator page
  3. Drop the blog folder on the generator, it will automatically process the articles
  4. Copy the feed.xml and index.json text to their respective files
  5. Commit and push the changes
  6. Optionally: toot on mastodon, add the toot URL in the page, commit and push

Not too shabby...

================================================ FILE: public/blog/articles/2024-08-25-vanilla-entity-encoding/example1.js ================================================ class MyComponent extends HTMLElement { connectedCallback() { const btn = ``; this.innerHTML = `

${this.getAttribute('bar')}

${this.getAttribute('xyzzy')}

${btn}
`; } } customElements.define('my-component', MyComponent); ================================================ FILE: public/blog/articles/2024-08-25-vanilla-entity-encoding/example2.js ================================================ function htmlEncode(s) { return s.replace(/[&<>'"]/g, tag => ({ '&': '&', '<': '<', '>': '>', "'": ''', '"': '"' }[tag])) } class MyComponent extends HTMLElement { connectedCallback() { const btn = ``; this.innerHTML = `

${htmlEncode(this.getAttribute('bar'))}

${htmlEncode(this.getAttribute('xyzzy'))}

${btn}
`; } } customElements.define('my-component', MyComponent); ================================================ FILE: public/blog/articles/2024-08-25-vanilla-entity-encoding/example3.js ================================================ import { html } from './html.js'; class MyComponent extends HTMLElement { connectedCallback() { const btn = html``; this.innerHTML = html`

${this.getAttribute('bar')}

${this.getAttribute('xyzzy')}

${btn}
`; } } customElements.define('my-component', MyComponent); ================================================ FILE: public/blog/articles/2024-08-25-vanilla-entity-encoding/html.js ================================================ class Html extends String { } /** * tag a string as html not to be encoded * @param {string} str * @returns {string} */ export const htmlRaw = str => new Html(str); /** * entity encode a string as html * @param {*} value The value to encode * @returns {string} */ export const htmlEncode = (value) => { // avoid double-encoding the same string if (value instanceof Html) { return value; } else { // https://stackoverflow.com/a/57448862/20980 return htmlRaw( String(value).replace(/[&<>'"]/g, tag => ({ '&': '&', '<': '<', '>': '>', "'": ''', '"': '"' }[tag])) ); } } /** * html tagged template literal, auto-encodes entities */ export const html = (strings, ...values) => htmlRaw(String.raw({ raw: strings }, ...values.map(htmlEncode))); ================================================ FILE: public/blog/articles/2024-08-25-vanilla-entity-encoding/index.html ================================================ Vanilla entity encoding A man working a printing press printing HTML code

Vanilla entity encoding

Joeri Sebrechts

Good enough

When I made the first version of the Plain Vanilla website, there were things that I would have liked to spend more time on, but that I felt didn't belong in a Good Enough™ version of the site. One of those things was defending against Cross-Site Scripting (XSS).

XSS is still in the OWASP Top Ten of security issues, but it's no longer as prevalent as it used to be. Frameworks have built in a lot of defenses, and when using their templating systems you have to go out of your way to inject code into the generated HTML. When eschewing frameworks we're reduced to standard templating in our web components, and those offer no defense against XSS.

Because of this, in the original site the Passing Data example on the Components page had an undocumented XSS bug. The name field could have scripts injected into it. I felt ambivalent about leaving that bug in. On the one hand, the code was very compact and neat by leaving it in. On the other hand it made that code a bad example that shouldn't be copied. I ended up choosing to leave it as-is because an example doesn't have to be production-grade and generating properly encoded HTML was not the point of that specific example. It's time however to circle back to that XSS bug and figure out how it would have been solved in a clean and readable way, if Santa really did want to bring his List application to production-level quality.

The problem

The basic problem we need to solve is that vanilla web components end up having a lot of code that looks like this:

If any of foo, bar, baz or xyzzy contain one of the dangerous HTML entities, we risk seeing our component break, and worst-case risk seeing an attacker inject a malicious payload into the page. Just as a reminder, those dangerous HTML entities are <, >, &, ' and ".

The fix, take one

A naive fix is creating a html-encoding function and using it consistently:

While this does work to defend against XSS, it is verbose and ugly, not pleasant to type and not pleasant to read. What really kills it though, is that it assumes attention to detail from us messy humans. We can never forget, never ever, to put a htmlEncode() around each and every variable. In the real world, that is somewhat unlikely.

What is needed is a solution that allows us to forget about entity encoding, by doing it automatically when we're templating. I drew inspiration from templating libraries that work in-browser and are based on tagged templates, like lit-html and htm. The quest was on to build the most minimalistic html templating function that encoded entities automatically.

The fix, take two

Ideally, the fixed example should look more like this:

The html`` tagged template function would automatically encode entities, in a way that we don't even have to think about it. Even when we nest generated HTML inside of another template, like with ${btn}, it should just magically work. It would be so minimal as to disappear in the background, barely impacting readability, maybe even improving it. You may be thinking that doing that correctly would involve an impressive amount of code. I must disappoint.

Those couple dozen lines of code are all that is needed. Let's go through it from top to bottom.

class Html extends String { }
The Html class is used to mark strings as encoded, so that they won't be encoded again.
export const htmlRaw = str => new Html(str);
Case in point, the htmlRaw function does the marking.
export const htmlEncode = ...
The earlier htmlEncode function is still doing useful work, only this time it will mark the resulting string as HTML, and it won't double-encode.
export const html = ...
The tagged template function that binds it together.

A nice upside of the html template function is that the html-in-template-string Visual Studio Code extension can detect it automatically and will syntax highlight the templated HTML. This is what example 3 looked like after I made it:

example 3 with syntax highlighting

Granted, there's still a bunch of boilerplate here, and that getAttribute gets unwieldy. But with this syntax highlighting enabled sometimes when I'm working on vanilla web components I forget it's not React and JSX, but just HTML and JS. It's surprising how nice of a development experience web standards can be if you embrace them.

I decided to leave the XSS bug in the Passing Data example, but now the Applications page has an explanation about entity encoding documenting this html template function. I can only hope people that work their way through the tutorial make it that far. For your convenience I also put the HTML templating function in its own separate html-literal repo on Github.

================================================ FILE: public/blog/articles/2024-08-30-poor-mans-signals/adder.html ================================================ Adder example ================================================ FILE: public/blog/articles/2024-08-30-poor-mans-signals/adder.js ================================================ import { signal, computed } from './signals.js'; customElements.define('x-adder', class extends HTMLElement { a = signal(1); b = signal(2); result = computed((a, b) => `${a} + ${b} = ${+a + +b}`, [this.a, this.b]); connectedCallback() { if (this.querySelector('input')) return; this.innerHTML = `

`; this.result.effect( () => this.querySelector('p').textContent = this.result); this.addEventListener('input', e => this[e.target.name].value = e.target.value); } }); ================================================ FILE: public/blog/articles/2024-08-30-poor-mans-signals/index.html ================================================ Poor man's signals Train signals with mountains in the distance

Poor man's signals

Joeri Sebrechts

Signals are all the rage right now. Everyone's doing them. Angular, and Solid, and Preact, and there are third party packages for just about every framework that doesn't already have them. There's even a proposal to add them to the language, and if that passes it's just a matter of time before all frameworks have them built in.

Living under a rock

In case you've been living under a rock, here's the example from Preact's documentation that neatly summarizes what signals do:

Simply put, signals wrap values and computations in a way that allows us to easily respond to every change to those values and results in a targeted way, without having to rerender the entire application in the way that we would do in React. In short, signals are an efficient and targeted way to respond to changes without having to do state comparison and DOM-diffing.

OK, so, if signals are so great, why am I trying to sell you on them on a vanilla web development blog? Don't worry! Vanilla web developers can have signals too.

Just a wrapper

Signals are at heart nothing more than a wrapper for a value that sends events when the value changes. That's nothing that a little trickery with the not well known but very handy EventTarget base class can't fix for us.

This gets us a very barebones signals experience:

But that's kind of ugly. The new keyword went out of fashion a decade ago, and that addEventListener sure is unwieldy. So let's add a little syntactic sugar.

Now our barebones example is a lot nicer to use:

The effect(fn) method will call the specified function, and also subscribe it to changes in the signal's value.

It also returns a dispose function that can be used to unregister the effect. However, a nice side effect of using EventTarget and browser built-in events as the reactivity primitive is that it makes the browser smart enough to garbage collect the signal and its effect when the signal goes out of scope. This means less chance for memory leaks even if we never call the dispose function.

Finally, the toString and valueOf magic methods allow for dropping .value in most places that the signal's value gets used. (But not in this example, because the console is far too clever for that.)

Does not compute

This signals implementation is already capable, but at some point it might be handy to have an effect based on more than one signal. That means supporting computed values. Where the base signals are a wrapper around a value, computed signals are a wrapper around a function.

The computed signal calculates its value from a function. It also depends on other signals, and when they change it will recompute its value. It's a bit obnoxious to have to pass the signals that it depends on as an additional parameter, but hey, I didn't title this article Rich man's signals.

This enables porting Preact's signals example to vanilla JS.

Can you use it in a sentence?

You may be thinking, all these console.log examples are fine and dandy, but how do you use this stuff in actual web development? This simple adder demonstrates how signals can be combined with web components:

And here's a live demo:

In case you were wondering, the if is there to prevent adding the effect twice if connectedCallback is called when the component is already rendered.

The full poor man's signals code in all its 36 line glory can be found in the tiny-signals repo on Github.

================================================ FILE: public/blog/articles/2024-08-30-poor-mans-signals/preact-example.js ================================================ import { signal, computed, effect } from "@preact/signals"; const name = signal("Jane"); const surname = signal("Doe"); const fullName = computed(() => `${name.value} ${surname.value}`); // Logs name every time it changes: effect(() => console.log(fullName.value)); // Logs: "Jane Doe" // Updating `name` updates `fullName`, which triggers the effect again: name.value = "John"; // Logs: "John Doe" ================================================ FILE: public/blog/articles/2024-08-30-poor-mans-signals/signals.js ================================================ export class Signal extends EventTarget { #value; get value () { return this.#value; } set value (value) { if (this.#value === value) return; this.#value = value; this.dispatchEvent(new CustomEvent('change')); } constructor (value) { super(); this.#value = value; } effect(fn) { fn(); this.addEventListener('change', fn); return () => this.removeEventListener('change', fn); } valueOf () { return this.#value; } toString () { return String(this.#value); } } export class Computed extends Signal { constructor (fn, deps) { super(fn(...deps)); for (const dep of deps) { if (dep instanceof Signal) dep.addEventListener('change', () => this.value = fn(...deps)); } } } export const signal = _ => new Signal(_); export const computed = (fn, deps) => new Computed(fn, deps); ================================================ FILE: public/blog/articles/2024-08-30-poor-mans-signals/signals1-use.js ================================================ const name = new Signal('Jane'); name.addEventListener('change', () => console.log(name.value)); name.value = 'John'; // Logs: John ================================================ FILE: public/blog/articles/2024-08-30-poor-mans-signals/signals1.js ================================================ class Signal extends EventTarget { #value; get value () { return this.#value; } set value (value) { if (this.#value === value) return; this.#value = value; this.dispatchEvent(new CustomEvent('change')); } constructor (value) { super(); this.#value = value; } } ================================================ FILE: public/blog/articles/2024-08-30-poor-mans-signals/signals2-use.js ================================================ const name = signal('Jane'); name.effect(() => console.log(name.value)); // Logs: Jane name.value = 'John'; // Logs: John ================================================ FILE: public/blog/articles/2024-08-30-poor-mans-signals/signals2.js ================================================ class Signal extends EventTarget { #value; get value () { return this.#value; } set value (value) { if (this.#value === value) return; this.#value = value; this.dispatchEvent(new CustomEvent('change')); } constructor (value) { super(); this.#value = value; } effect(fn) { fn(); this.addEventListener('change', fn); return () => this.removeEventListener('change', fn); } valueOf () { return this.#value; } toString () { return String(this.#value); } } const signal = _ => new Signal(_); ================================================ FILE: public/blog/articles/2024-08-30-poor-mans-signals/signals3-use.js ================================================ const name = signal('Jane'); const surname = signal('Doe'); const fullName = computed(() => `${name} ${surname}`, [name, surname]); // Logs name every time it changes: fullName.effect(() => console.log(fullName.value)); // -> Jane Doe // Updating `name` updates `fullName`, which triggers the effect again: name.value = 'John'; // -> John Doe ================================================ FILE: public/blog/articles/2024-08-30-poor-mans-signals/signals3.js ================================================ class Computed extends Signal { constructor (fn, deps) { super(fn(...deps)); for (const dep of deps) { if (dep instanceof Signal) dep.addEventListener('change', () => this.value = fn(...deps)); } } } const computed = (fn, deps) => new Computed(fn, deps); ================================================ FILE: public/blog/articles/2024-09-03-unix-philosophy/adder.svelte ================================================

{a} + {b} = {a + b}

================================================ FILE: public/blog/articles/2024-09-03-unix-philosophy/bind.js ================================================ /** * Render a template (element or string) to an html fragment * while connecting it up to data using Vue-style shorthand binding syntax. * * - `@click="handleClick"` -> `click` event and `target.handleClick` method * - `@change="mySignal"` -> `change` event will set `target.mySignal.value` to e.target.value * - `@change="myProp"` -> `change` event will set `target.myProp` to e.target.value * - `:textContent="text"` -> elem `textContent` property is set to `this.text` property once * (also supports special shorthand `:text` for textContent and `:html` for innerHTML) * - `:value="mySignal"` -> elem `value` attribute is bound to the signal `target.mySignal`'s value * * @param {*} template The template to be bound to an object * @param {*} target The object being targeted for binding * @returns fragment */ export const bind = (template, target) => { if (!template.content) { const text = template; template = document.createElement('template'); template.innerHTML = text; } const fragment = template.content.cloneNode(true); // iterate over all nodes in the fragment and bind them // based on https://hawkticehurst.com/2024/05/bring-your-own-base-class/ const iterator = document.createNodeIterator( fragment, NodeFilter.SHOW_ELEMENT, { // Reject any node that is not an HTML element acceptNode: (node) => { if (!(node instanceof HTMLElement)) return NodeFilter.FILTER_REJECT; return NodeFilter.FILTER_ACCEPT; }, } ); let node; while (node = iterator.nextNode()) { if (!node) return; const elem = node; // list of all properties // const properties = getAllProperties(elem); // copy NamedNodeMap because we're about to change it for (const attr of Array(...node.attributes)) { // check for custom event listener attributes if (attr.name.startsWith('@')) { const event = attr.name.slice(1); const property = attr.value; let listener; // if we're binding the event to a function, call it directly if (typeof target[property] === 'function') { listener = target[property].bind(target); // if we're binding to a signal, set the signal's value } else if (typeof target[property] === 'object' && typeof target[property].value !== 'undefined') { listener = e => target[property].value = e.target.value; // fallback: assume we're binding to a property, set the property's value } else { listener = e => target[property] = e.target.value; } elem.addEventListener(event, listener); // remove (non-standard) attribute from element elem.removeAttributeNode(attr); // check for custom property/attribute binding attributes } else if (attr.name.startsWith(':')) { // extract the name and value of the attribute/property let name = attr.name.slice(1); const property = getPropertyForAttribute(name, target); // properties.values().find(k => k.toLowerCase() === name.toLowerCase()); const setter = property ? () => elem[property] = target[attr.value] : () => elem.setAttribute(name, target[attr.value]); setter(); // if we're binding to a signal, listen to updates target[attr.value].effect?.(setter); // remove (non-standard) attribute from element elem.removeAttributeNode(attr); } } } return fragment; } function getPropertyForAttribute(name, obj) { switch (name.toLowerCase()) { case 'text': case 'textcontent': return 'textContent'; case 'html': case 'innerhtml': return 'innerHTML'; default: for (let prop of Object.getOwnPropertyNames(obj)) { if (prop.toLowerCase() === name.toLowerCase()) { return prop; } } } } ================================================ FILE: public/blog/articles/2024-09-03-unix-philosophy/bind1.js ================================================ export const bind = (template) => { const fragment = template.content.cloneNode(true); // iterate over all nodes in the fragment const iterator = document.createNodeIterator( fragment, NodeFilter.SHOW_ELEMENT, { // reject any node that is not an HTML element acceptNode: (node) => { if (!(node instanceof HTMLElement)) return NodeFilter.FILTER_REJECT; return NodeFilter.FILTER_ACCEPT; }, } ); let node; while (node = iterator.nextNode()) { if (!node) return; const elem = node; for (const attr of Array(...node.attributes)) { // check for event binding directive if (attr.name.startsWith('@')) { // TODO: bind event ... elem.removeAttributeNode(attr); // check for property/attribute binding directive } else if (attr.name.startsWith(':')) { // TODO: bind data ... elem.removeAttributeNode(attr); } } } return fragment; } ================================================ FILE: public/blog/articles/2024-09-03-unix-philosophy/bind2-partial.js ================================================ export const bind = (template, target) => { if (!template.content) { const text = template; template = document.createElement('template'); template.innerHTML = text; } const fragment = template.content.cloneNode(true); // ... } ================================================ FILE: public/blog/articles/2024-09-03-unix-philosophy/bind3-partial.js ================================================ // check for custom event listener attributes if (attr.name.startsWith('@')) { const event = attr.name.slice(1); const property = attr.value; let listener; // if we're binding the event to a function, call it directly if (typeof target[property] === 'function') { listener = target[property].bind(target); // if we're binding to a signal, set the signal's value } else if (typeof target[property] === 'object' && typeof target[property].value !== 'undefined') { listener = e => target[property].value = e.target.value; // fallback: assume we're binding to a property, set the property's value } else { listener = e => target[property] = e.target.value; } elem.addEventListener(event, listener); // remove (non-standard) attribute from element elem.removeAttributeNode(attr); } ================================================ FILE: public/blog/articles/2024-09-03-unix-philosophy/bind4-partial.js ================================================ // ... if (attr.name.startsWith(':')) { // extract the name and value of the attribute/property let name = attr.name.slice(1); const property = getPropertyForAttribute(name, target); const setter = property ? () => elem[property] = target[attr.value] : () => elem.setAttribute(name, target[attr.value]); setter(); // if we're binding to a signal, listen to updates if (target[attr.value]?.effect) { target[attr.value].effect(setter); // if we're binding to a property, listen to the target's updates } else if (target.addEventListener) { target.addEventListener('change', setter); } // remove (non-standard) attribute from element elem.removeAttributeNode(attr); } // ... function getPropertyForAttribute(name, obj) { switch (name.toLowerCase()) { case 'text': case 'textcontent': return 'textContent'; case 'html': case 'innerhtml': return 'innerHTML'; default: for (let prop of Object.getOwnPropertyNames(obj)) { if (prop.toLowerCase() === name.toLowerCase()) { return prop; } } } } ================================================ FILE: public/blog/articles/2024-09-03-unix-philosophy/example-bind3/bind.js ================================================ export const bind = (template, target) => { if (!template.content) { const text = template; template = document.createElement('template'); template.innerHTML = text; } const fragment = template.content.cloneNode(true); // iterate over all nodes in the fragment const iterator = document.createNodeIterator( fragment, NodeFilter.SHOW_ELEMENT, { // reject any node that is not an HTML element acceptNode: (node) => { if (!(node instanceof HTMLElement)) return NodeFilter.FILTER_REJECT; return NodeFilter.FILTER_ACCEPT; }, } ); let node; while (node = iterator.nextNode()) { if (!node) return; const elem = node; for (const attr of Array(...node.attributes)) { // check for event binding directive if (attr.name.startsWith('@')) { // check for custom event listener attributes if (attr.name.startsWith('@')) { const event = attr.name.slice(1); const property = attr.value; let listener; // if we're binding the event to a function, call it directly if (typeof target[property] === 'function') { listener = target[property].bind(target); // if we're binding to a signal, set the signal's value } else if (typeof target[property] === 'object' && typeof target[property].value !== 'undefined') { listener = e => target[property].value = e.target.value; // fallback: assume we're binding to a property, set the property's value } else { listener = e => target[property] = e.target.value; } elem.addEventListener(event, listener); // remove (non-standard) attribute from element elem.removeAttributeNode(attr); } // check for property/attribute binding directive } else if (attr.name.startsWith(':')) { // TODO: bind data ... elem.removeAttributeNode(attr); } } } return fragment; } ================================================ FILE: public/blog/articles/2024-09-03-unix-philosophy/example-bind3/example.html ================================================ Binding example ================================================ FILE: public/blog/articles/2024-09-03-unix-philosophy/example-bind3/example.js ================================================ import { bind } from './bind.js'; import { signal } from './signals.js'; customElements.define('x-example', class Example extends HTMLElement { set a(value) { this.setAttribute('a', value); this.querySelector('label[for=a] span').textContent = value; } set b(value) { this.setAttribute('b', value); this.querySelector('label[for=b] span').textContent = value; } c = signal(''); connectedCallback() { this.append(bind(`
Result:
`, this)); this.c.effect(() => this.querySelector('label[for=c] span').textContent = this.c); } onInputA (e) { this.a = e.target.value; } onClick() { this.querySelector('#result').textContent = +this.getAttribute('a') + +this.getAttribute('b') + +this.c; } }); ================================================ FILE: public/blog/articles/2024-09-03-unix-philosophy/example-bind3/signals.js ================================================ export class Signal extends EventTarget { #value; get value () { return this.#value; } set value (value) { if (this.#value === value) return; this.#value = value; this.dispatchEvent(new CustomEvent('change')); } constructor (value) { super(); this.#value = value; } effect(fn) { fn(); this.addEventListener('change', fn); return () => this.removeEventListener('change', fn); } valueOf () { return this.#value; } toString () { return String(this.#value); } } export class Computed extends Signal { constructor (fn, deps) { super(fn(...deps)); for (const dep of deps) { if (dep instanceof Signal) dep.addEventListener('change', () => this.value = fn(...deps)); } } } export const signal = _ => new Signal(_); export const computed = (fn, deps) => new Computed(fn, deps); ================================================ FILE: public/blog/articles/2024-09-03-unix-philosophy/example-combined/adder.js ================================================ import { bind } from './bind.js'; import { signal, computed } from './signals.js'; import { html } from './html.js'; customElements.define('x-adder', class Adder extends HTMLElement { a = signal(); b = signal(); result = computed(() => html`${+this.a} + ${+this.b} = ${+this.a + +this.b}`, [this.a, this.b]); connectedCallback() { this.a.value ??= this.getAttribute('a') || 0; this.b.value ??= this.getAttribute('b') || 0; this.append(bind(html`

`, this)); } }); ================================================ FILE: public/blog/articles/2024-09-03-unix-philosophy/example-combined/bind.js ================================================ /** * Render a template (element or string) to an html fragment * while connecting it up to data using Vue-style shorthand binding syntax. * * - `@click="handleClick"` -> `click` event and `target.handleClick` method * - `@change="mySignal"` -> `change` event will set `target.mySignal.value` to e.target.value * - `@change="myProp"` -> `change` event will set `target.myProp` to e.target.value * - `:textContent="text"` -> elem `textContent` property is set to `this.text` property once * (also supports special shorthand `:text` for textContent and `:html` for innerHTML) * - `:value="mySignal"` -> elem `value` attribute is bound to the signal `target.mySignal`'s value * * @param {*} template The template to be bound to an object * @param {*} target The object being targeted for binding * @returns fragment * @license Unlicense */ export const bind = (template, target) => { if (!template.content) { const text = template; template = document.createElement('template'); template.innerHTML = text; } const fragment = template.content.cloneNode(true); // iterate over all nodes in the fragment and bind them // based on https://hawkticehurst.com/2024/05/bring-your-own-base-class/ const iterator = document.createNodeIterator( fragment, NodeFilter.SHOW_ELEMENT, { // Reject any node that is not an HTML element acceptNode: (node) => { if (!(node instanceof HTMLElement)) return NodeFilter.FILTER_REJECT; return NodeFilter.FILTER_ACCEPT; }, } ); let node; while (node = iterator.nextNode()) { if (!node) return; const elem = node; for (const attr of Array(...node.attributes)) { // check for custom event listener attributes if (attr.name.startsWith('@')) { const event = attr.name.slice(1); const property = attr.value; let listener; // if we're binding the event to a function, call it directly if (typeof target[property] === 'function') { listener = target[property].bind(target); // if we're binding to a signal, set the signal's value } else if (typeof target[property] === 'object' && typeof target[property].value !== 'undefined') { listener = e => target[property].value = e.target.value; // fallback: assume we're binding to a property, set the property's value } else { listener = e => target[property] = e.target.value; } elem.addEventListener(event, listener); // remove (non-standard) attribute from element elem.removeAttributeNode(attr); // check for custom property/attribute binding attributes } else if (attr.name.startsWith(':')) { // extract the name and value of the attribute/property let name = attr.name.slice(1); const property = getPropertyForAttribute(name, target); const setter = property ? () => elem[property] = target[attr.value] : () => elem.setAttribute(name, target[attr.value]); setter(); // if we're binding to a signal, listen to updates target[attr.value].effect?.(setter); // remove (non-standard) attribute from element elem.removeAttributeNode(attr); } } } return fragment; } function getPropertyForAttribute(name, obj) { switch (name.toLowerCase()) { case 'text': case 'textcontent': return 'textContent'; case 'html': case 'innerhtml': return 'innerHTML'; default: for (let prop of Object.getOwnPropertyNames(obj)) { if (prop.toLowerCase() === name.toLowerCase()) { return prop; } } } } ================================================ FILE: public/blog/articles/2024-09-03-unix-philosophy/example-combined/example.html ================================================ Binding example ================================================ FILE: public/blog/articles/2024-09-03-unix-philosophy/example-combined/html.js ================================================ class Html extends String { } /** * tag a string as html not to be encoded * @param {string} str * @returns {string} */ export const htmlRaw = str => new Html(str); /** * entity encode a string as html * @param {*} value The value to encode * @returns {string} */ export const htmlEncode = (value) => { // avoid double-encoding the same string if (value instanceof Html) { return value; } else { // https://stackoverflow.com/a/57448862/20980 return htmlRaw( String(value).replace(/[&<>'"]/g, tag => ({ '&': '&', '<': '<', '>': '>', "'": ''', '"': '"' }[tag])) ); } } /** * html tagged template literal, auto-encodes entities */ export const html = (strings, ...values) => htmlRaw(String.raw({ raw: strings }, ...values.map(htmlEncode))); ================================================ FILE: public/blog/articles/2024-09-03-unix-philosophy/example-combined/signals.js ================================================ export class Signal extends EventTarget { #value; get value () { return this.#value; } set value (value) { if (this.#value === value) return; this.#value = value; this.dispatchEvent(new CustomEvent('change')); } constructor (value) { super(); this.#value = value; } effect(fn) { fn(); this.addEventListener('change', fn); return () => this.removeEventListener('change', fn); } valueOf () { return this.#value; } toString () { return String(this.#value); } } export class Computed extends Signal { constructor (fn, deps) { super(fn(...deps)); for (const dep of deps) { if (dep instanceof Signal) dep.addEventListener('change', () => this.value = fn(...deps)); } } } export const signal = _ => new Signal(_); export const computed = (fn, deps) => new Computed(fn, deps); ================================================ FILE: public/blog/articles/2024-09-03-unix-philosophy/index.html ================================================ A unix philosophy for web development A pattern of connected spheres

A unix philosophy for web development

Joeri Sebrechts

Web components have their malcontents. While frameworks have done their best to provide a place for web components to fit into their architecture, the suit never fits quite right, and framework authors have not been shy about expressing their disappointment. Here's Ryan Carniato of SolidJS explaining what's wrong with web components:

The collection of standards (Custom Elements, HTML Templates, Shadow DOM, and formerly HTML Imports) put together to form Web Components on the surface seem like they could be used to replace your favourite library or framework. But they are not an advanced templating solution. They don't improve your ability to render or update the DOM. They don't manage higher-level concerns for you like state management.
Ryan Carniato

While this criticism is true, perhaps it's besides the point. Maybe web components were never meant to solve those problems anyway. Maybe there are ways to solve those problems in a way that dovetails with web components as they exist. In the main components tutorial I've already explained what they can do, now let's see what can be done about the things that they can't do.

The Unix Philosophy

The Unix operating system carries with it a culture and philosophy of system design, which carries over to the command lines of today's Unix-like systems like Linux and MacOS. This philosophy can be summarized as follows:

What if we look at the various technologies that comprise web components as just programs, part of a Unix-like system of web development that we collectively call the browser platform? In that system we can do better than text and use the DOM as the universal interface between programs, and we can extend the system with a set of single purpose independent "programs" (functions) that fully embrace the DOM by augmenting it instead of replacing it.

In a sense this is the most old-school way of building web projects, the one people who "don't know any better" automatically gravitate to. What us old-school web developers did before Vue and Solid and Svelte, before Angular and React, before Knockout and Ember and Backbone, before even jQuery, was have a bunch of functions in utilities.js that we copied along from project to project. But, you know, sometimes old things can become new again.

In previous posts I've already covered a html() function for vanilla entity encoding, and a signal() function that provides a tiny signals implementation that can serve as a lightweight system for state management. That still leaves a missing link between the state managed by the signals and the DOM that is rendered from safely entity-encoded HTML. What we need is a bind() function that can bind data to DOM elements and bind DOM events back to data.

Finding inspiration

In order to bind a template to data, we need a way of describing that behavior in the HTML markup. Well-trodden paths are often the best starting place to look for inspiration. I like Vue's template syntax, because it is valid HTML but just augmented, and because it is proven. Vue's templates only pretend to be HTML because they're actually compiled to JavaScript behind the scenes, but let's start there as an API. This is what it looks like:

<img :src="imageSrc" />
Bind src to track the value of the imageSrc property of the current component. Vue is smart enough to set a property if one exists, and falls back to setting an attribute otherwise. (If that confuses you, read about attributes and properties first.)
<button @click="doThis"></button>
Bind the click event to the doThis method of the current component.

By chance I came across this article about making a web component base class. In the section Declarative interactivity the author shows a way to do the Vue-like event binding syntax on a vanilla web component. This is what inspired me to develop the concept into a generic binding function and write this article.

Just an iterator

The heart of the binding function is an HTML fragment iterator. After all, before we can bind attributes we need to first find the ones that have binding directives.

This code will take an HTML template element, clone it to a document fragment, and then iterate over all the nodes in the fragment, discovering their attributes. Then for each attribute a check is made to see if it's a binding directive (@ or :). The node is then bound to data according to the directive attribute (shown here as TODO's), and the attribute is removed from the node. At the end the bound fragment is returned for inserting into the DOM.

The benefit of using a fragment is that it is disconnected from the main DOM, while still offering all of the DOM API's. That means we can easily create a node iterator to walk over it and discover all the attributes with binding directives, modify those nodes and attributes in-place, and still be sure we're not causing DOM updates in the main page until the fragment is inserted there. This makes the bind function very fast.

If you're thinking "woah dude, that's a lot of code and a lot of technobabble, I ain't reading all that," then please, I implore you to read through the code line by line, and you'll see it will all make sense.

Of course, we also need to have something to bind to, so we need to add a second parameter. At the same time, it would be nice to just be able to pass in a string and have it auto-converted into a template. The beginning of our bind function then ends up looking like this:

That just leaves us the TODO's. We can make those as simple or complicated as we want. I'll pick a middle ground.

Binding to events

This 20 line handler binds events to methods, signals or properties:

That probably doesn't explain much, so let me give an example of what this enables:

If you're not familiar with the signal() function, check out the tiny signals implementation in the previous post. For now you can also just roll with it.

Not a bad result for 20 lines of code.

Binding to data

Having established the pattern for events that automatically update properties, we now reverse the polarity to make data values automatically set element properties or attributes.

The getPropertyForAttribute function is necessary because the attributes that contain the directives will have names that are case-insensitive, and these must be mapped to property names that are case-sensitive. Also, the :text and :html shorthand notations replace the role of v-text and v-html in Vue's template syntax.

When the value of the target's observed property changes, we need to update the bound element's property or attribute. This means a triggering 'change' event is needed that is then subscribed to. A framework's templating system will compare state across time, and detect the changed values automatically. Lacking such a system we need a light-weight alternative.

When the property being bound to is a signal, this code registers an effect on the signal. When the property is just a value, it registers an event listener on the target object, making it the responsibility of that target object to dispatch the 'change' event when values change. This approach isn't going to get many points for style, but it does work.

Check out the completed bind.js code.

Bringing the band together

In the article Why I don't use web components Svelte's Rich Harris lays out the case against web components. He demonstrates how this simple 9 line Svelte component <Adder a={1} b={2}/> becomes an incredible verbose 59 line monstrosity when ported to a vanilla web component.

Now that we have assembled our three helper functions html(), signal() and bind() on top of the web components baseline, at a total budget of around 150 lines of code, how close can we get for a web component <x-adder a="1" b="2"></x-adder>?

To be fair, that's still twice the lines of code, but it describes clearly what it does, and really that is all you need. And I'm just shooting in the wind here, trying stuff out. Somewhere out there could be a minimal set of functions that transforms web components into something resembling a framework, and the idea excites me! Who knows, maybe in a few years the web community will return to writing projects in vanilla web code, dragging along the modern equivalent of utilities.js from project to project...


What do you think?

================================================ FILE: public/blog/articles/2024-09-06-how-fast-are-web-components/index.html ================================================ How fast are web components? A snail and a hare in a race

How fast are web components?

Joeri Sebrechts

It is often said that web components are slow. This was also my experience when I first tried building web components a few years ago. At the time I was using the Stencil framework, because I didn't feel confident that they could be built well without a framework. Performance when putting hundreds of them on a page was so bad that I ended up going back to React.

But as I've gotten deeper into vanilla web development I started to realize that maybe I was just using it wrong. Perhaps web components can be fast, if built in a light-weight way. This article is an attempt to settle the question "How fast are web components?".

The lay of the land

What kinds of questions did I want answered?

To figure out the answer I made a benchmark as a vanilla web page (of course), that renders thousands of very simple components containing only <span>.</span> and measured the elapsed time. This benchmark was then run on multiple devices and multiple browsers to figure out performance characteristics. The ultimate goal of this test is to figure out the absolute best performance that can be extracted from the most minimal web component.

To get a performance range I used two devices for testing:

Between these devices is a 7x CPU performance gap.

The test

The test is simple: render thousands of components using a specific technique, call requestAnimationFrame() repeatedly until they actually render, then measure elapsed time. This produces a components per millisecond number.

The techniques being compared:

This test was run on M1 in Brave, Chrome, Edge, Firefox and Safari. And on Chi in Chrome and Firefox. It was run for 10 iterations and a geometric mean was taken of the results.

The results

First, let's compare techniques. The number here is components per millisecond, so higher is better.

Author's note: the numbers from the previous version of this article are crossed out.

Chrome on M1
techniquecomponents/ms
innerHTML143 135
append233 239
append (buffered)228 239
shadow + innerHTML132 127
shadow + append183 203
template + append181 198
textcontent345
direct461
lit133 137
react pure275 338
react + wc172 212
append (norender)1393
shadow (norender)814
direct (norender)4277
lit (norender)880
Chrome on Chi, best of three
techniquecomponents/ms
innerHTML25 29
append55 55
append (buffered)56 59
shadow + innerHTML24 26
shadow + append36 47
template + append45 46
textcontent81
direct116
lit30 33
react pure77 87
react + wc45 52
append (norender)434
shadow (norender)231
direct (norender)1290
lit (norender)239

One relief right off the bat is that even the slowest implementation on the slow device renders 100.000 components in 4 seconds. React is roughly in the same performance class as well-written web components. That means for a typical web app performance is not a reason to avoid web components.

As far as web component technique goes, the performance delta between the fastest and the slowest technique is around 2x, so again for a typical web app that difference will not matter. Things that slow down web components are shadow DOM and innerHTML. Appending directly created elements or cloned templates and avoiding shadow DOM is the right strategy for a well-performing web component that needs to end up on the page thousands of times.

On the slow device the Lit framework is a weak performer, probably due to its use of shadow DOM and JS-heavy approaches. Meanwhile, pure React is the best performer, because while it does more work in creating the virtual DOM and diffing it to the real DOM, it benefits from not having to initialize the web component class instances. Consequently, when wrapping web components inside React components we see React's performance advantage disappear, and that it adds a performance tax. In the grand scheme of things however, the differences between React and optimized web components remains small.

The fast device is up to 5x faster than the slow device in Chrome, depending on the technique used, so it is really worth testing applications on slow devices to get an idea of the range of performance.

Next, let's compare browsers:

M1, append, best of three
browsercomponents/ms
Brave146 145
Chrome233 239
Edge224 237
Firefox232 299
Safari260 239
Chi, append, best of three
browsercomponents/ms
Chrome55 55
Firefox180 77

Brave is really slow, probably because of its built-in ad blocking. Ad blocking extensions also slow down the other browsers by a lot. Safari, Chrome and Edge end up in roughly the same performance bucket. Firefox is the best performer overall. Using the "wrong" browser can halve the performance of a machine.

Author's note: due to a measurement error in measuring elapsed time, the previous version of this article had Safari as fastest and Firefox as middle of the pack.

There is a large performance gap when you compare the slowest technique on the slowest browser on the slowest device, with its fastest opposite combo. Specifically, there is a 16x performance gap:

That means it becomes worthwhile to carefully consider technique when having to support a wide range of browsers and devices, because a bad combination may lead to a meaningfully degraded user experience. And of course, you should always test your web app on a slow device to make sure it still works ok.

Bottom line

I feel confident now that web components can be fast enough for almost all use cases where someone might consider React instead.

However, it does matter how they are built. Shadow DOM should not be used for smaller often used web components, and the contents of those smaller components should be built using append operations instead of innerHTML. The use of web component frameworks might impact their performance significantly, and given how easy it is to write vanilla web components I personally don't see the point behind Lit or Stencil. YMMV.

The full benchmark code and results can be found on Github.

================================================ FILE: public/blog/articles/2024-09-09-sweet-suspense/error-boundary-partial.html ================================================

Something went wrong

Loading...

================================================ FILE: public/blog/articles/2024-09-09-sweet-suspense/error-boundary.js ================================================ export class ErrorBoundary extends HTMLElement { static showError(sender, error) { if (!error) throw new Error('ErrorBoundary.showError: expected two arguments but got one'); const boundary = sender.closest('x-error-boundary'); if (boundary) { boundary.error = error; } else { console.error('unable to find x-error-boundary to show error'); console.error(error); } } #error; #errorSlot; #contentSlot; get error() { return this.#error; } set error(error) { if (!this.#errorSlot) return; this.#error = error; this.#errorSlot.style.display = error ? 'contents' : 'none'; this.#contentSlot.style.display = !error ? 'contents' : 'none'; if (error) { this.#errorSlot.assignedElements().forEach(element => { if (Object.hasOwn(element, 'error')) { element.error = error; } else { element.setAttribute('error', error?.message || error); } }); this.dispatchEvent(new CustomEvent('error', { detail: error })); } } constructor() { super(); this.attachShadow({ mode: 'open' }); this.#errorSlot = document.createElement('slot'); this.#errorSlot.style.display = 'none'; this.#errorSlot.name = 'error'; // default error message this.#errorSlot.textContent = 'Something went wrong.'; this.#contentSlot = document.createElement('slot'); this.shadowRoot.append(this.#errorSlot, this.#contentSlot); } reset() { this.error = null; } connectedCallback() { this.style.display = 'contents'; } } customElements.define('x-error-boundary', ErrorBoundary); ================================================ FILE: public/blog/articles/2024-09-09-sweet-suspense/example/components/error-boundary.js ================================================ /** * A vanilla version of react-error-boundary ( https://github.com/bvaughn/react-error-boundary ) * * Usage: * * *

Something went wrong

* * Shows the error if loading fails: * * *
* * @license MIT */ export class ErrorBoundary extends HTMLElement { /** * Find the nearest error boundary to the sender element and make it show an error * @param {*} sender The element that sends the error * @param {*} error Error or string, the error to show */ static showError(sender, error) { if (!error) throw new Error('ErrorBoundary.showError: expected two arguments but got one'); const boundary = sender.closest('x-error-boundary'); if (boundary) { boundary.error = error; } else { console.error('unable to find x-error-boundary to show error'); console.error(error); } } #error; #errorSlot; #contentSlot; get error() { return this.#error; } set error(error) { if (!this.#errorSlot) return; this.#error = error; this.#errorSlot.style.display = error ? 'contents' : 'none'; this.#contentSlot.style.display = !error ? 'contents' : 'none'; if (error) { this.#errorSlot.assignedElements().forEach(element => { if (Object.hasOwn(element, 'error')) { element.error = error; } else { element.setAttribute('error', error?.message || error); } }); this.dispatchEvent(new CustomEvent('error', { detail: error })); } } constructor() { super(); this.attachShadow({ mode: 'open' }); this.#errorSlot = document.createElement('slot'); this.#errorSlot.style.display = 'none'; this.#errorSlot.name = 'error'; // default error message this.#errorSlot.textContent = 'Something went wrong.'; this.#contentSlot = document.createElement('slot'); this.shadowRoot.append(this.#errorSlot, this.#contentSlot); } reset() { this.error = null; } connectedCallback() { this.style.display = 'contents'; } } export const registerErrorBoundary = () => customElements.define('x-error-boundary', ErrorBoundary); ================================================ FILE: public/blog/articles/2024-09-09-sweet-suspense/example/components/error-message.js ================================================ class ErrorMessage extends HTMLElement { connectedCallback() { this.update(); } static get observedAttributes() { return ['error']; } attributeChangedCallback() { this.update(); } update() { const errorMsg = this.getAttribute('error') || ''; this.textContent = errorMsg; } } export const registerErrorMessage = () => customElements.define('x-error-message', ErrorMessage); ================================================ FILE: public/blog/articles/2024-09-09-sweet-suspense/example/components/hello-world/hello-world.js ================================================ import { Suspense } from '../suspense.js'; import { later } from './later.js'; class HelloWorldComponent extends HTMLElement { connectedCallback() { this.innerHTML = `

Hello world!

`; const btnLoad = this.querySelector('button#load'); btnLoad.onclick = () => { // simulate loading of data Suspense.waitFor(this, later(1000)); }; const btnError = this.querySelector('button#error'); btnError.onclick = () => { // simulate loading of data that ends in an error Suspense.waitFor(this, later(1000).then(() => { throw new Error('An error, as expected.'); })); } } } export default function register() { customElements.define('x-hello-world', HelloWorldComponent); } ================================================ FILE: public/blog/articles/2024-09-09-sweet-suspense/example/components/hello-world/later.js ================================================ export function later(delay) { return new Promise(function(resolve) { setTimeout(resolve, delay); }); } ================================================ FILE: public/blog/articles/2024-09-09-sweet-suspense/example/components/lazy.js ================================================ import { Suspense } from './suspense.js'; /** * A vanilla version of React's lazy() function * inspired by https://css-tricks.com/an-approach-to-lazy-loading-custom-elements/ * * Usage: * * Will load default function from ./components//.js and execute it. * Only direct children are lazy-loaded, and only on initial DOM insert. * * Pass the root attribute to modify the path to load relative to the current document. * * * Put the lazy-path attribute on a custom element to specify the path to the JS file to load. * * @license MIT */ class Lazy extends HTMLElement { connectedCallback() { this.style.display = 'contents'; this.#loadLazy(); } /** * Find direct child custom elements that need loading, then load them */ #loadLazy() { const elements = [...this.children].filter(_ => _.localName.includes('-')); const unregistered = elements.filter(_ => !customElements.get(_.localName)); if (unregistered.length) { Suspense.waitFor(this, ...unregistered.map(_ => this.#loadElement(_)) ); } } /** * Load a custom element * @param {*} element * @returns {Promise} a promise that settles when loading completes or fails */ #loadElement(element) { // does the element advertise its own path? let url = element.getAttribute('lazy-path'); if (!url) { // strip leading x- off the name const cleanName = element.localName.replace(/^x-/, '').toLowerCase(); // root directory to load from, relative to current document const rootDir = this.getAttribute('root') || './components/'; // assume component is in its own folder url = `${rootDir}${cleanName}/${cleanName}.js`; } // dynamically import, then register if not yet registered return import(new URL(url, document.location)).then(module => !customElements.get(element.localName) && module && module.default()); } } export const registerLazy = () => customElements.define('x-lazy', Lazy); ================================================ FILE: public/blog/articles/2024-09-09-sweet-suspense/example/components/suspense.js ================================================ import { ErrorBoundary } from './error-boundary.js'; /** * A vanilla version of React's Suspense * * Usage: * * *

Loading...

*

* While it loads it shows the fallback: * *

*
* * @license MIT */ export class Suspense extends HTMLElement { /** * Find the nearest suspense to the sender element and make it wait for the promises to complete. * @param {*} sender The element that sends the promise * @param {...Promise} promises */ static waitFor(sender, ...promises) { const suspense = sender.closest('x-suspense'); if (suspense) suspense.addPromises(...promises); } #fallbackSlot; #contentSlot; #waitingForPromise; set #loading(isLoading) { if (!this.#fallbackSlot) return; this.#fallbackSlot.style.display = isLoading ? 'contents' : 'none'; this.#contentSlot.style.display = !isLoading ? 'contents' : 'none'; } constructor() { super(); this.attachShadow({ mode: 'open' }); this.#fallbackSlot = document.createElement('slot'); this.#fallbackSlot.style.display = 'none'; this.#fallbackSlot.name = 'fallback'; this.#contentSlot = document.createElement('slot'); this.shadowRoot.append(this.#fallbackSlot, this.#contentSlot); } connectedCallback() { this.style.display = 'contents'; } /** * Wait for one or more promises to settle, showing fallback content * @param {...Promise} promises */ addPromises(...promises) { if (!promises.length) return; this.#loading = true; // combine into previous promises if there are any const newPromise = this.#waitingForPromise = Promise.allSettled([...promises, this.#waitingForPromise]); // wait for all promises to complete newPromise.then(settled => { // if no more promises were added, we're done if (newPromise === this.#waitingForPromise) { this.#loading = false; // if a promise failed, show an error const failed = settled.find(_ => _.status === 'rejected'); if (failed) ErrorBoundary.showError(this, failed.reason); } }); } } export const registerSuspense = () => customElements.define('x-suspense', Suspense); ================================================ FILE: public/blog/articles/2024-09-09-sweet-suspense/example/index.html ================================================ Lazy, Suspense and Error Boundary example ================================================ FILE: public/blog/articles/2024-09-09-sweet-suspense/example/index.js ================================================ import { registerLazy } from './components/lazy.js'; import { registerSuspense } from './components/suspense.js'; import { registerErrorBoundary } from './components/error-boundary.js'; import { registerErrorMessage } from './components/error-message.js'; customElements.define('x-demo', class extends HTMLElement { constructor() { super(); registerLazy(); registerSuspense(); registerErrorBoundary(); registerErrorMessage(); } connectedCallback() { this.innerHTML = `

Lazy loading demo

Click to load..

`; const resetBtn = this.querySelector('button#error-reset') resetBtn.onclick = () => { this.querySelector('x-error-boundary').reset(); resetBtn.setAttribute('disabled', true); }; const loadBtn = this.querySelector('button#lazy-load'); loadBtn.onclick = () => { this.querySelector('div#lazy-load-div').innerHTML = `

Loading...

` this.querySelector('x-error-boundary').addEventListener('error', _ => { resetBtn.removeAttribute('disabled'); }); loadBtn.setAttribute('disabled', true); }; } }); ================================================ FILE: public/blog/articles/2024-09-09-sweet-suspense/index.html ================================================ Sweet Suspense A shadowed figure in a city square, waiting on a woman

Sweet Suspense

I was reading Addy Osmani and Hassan Djirdeh's book Building Large Scale Web Apps. (Which, by the way, I can definitely recommend.) In it they cover all the ways to make a React app sing at scale. The chapter on Modularity was especially interesting to me, because JavaScript modules are a common approach to modularity in both React and vanilla web code.

In that chapter on Modularity there was one particular topic that caught my eye, and it was the use of lazy() and Suspense, paired with an ErrorBoundary. These are the primitives that React gives us to asynchronously load UI components and their data on-demand while showing a fallback UI, and replace the UI with an error message when something goes wrong. If you're not familiar, here's a good overview page.

It was at that time that I was visited by the imp of the perverse, which posed to me a simple challenge: can you bring React's lazy loading primitives to vanilla web components? To be clear, there are many ways to load web components lazily. This is well-trodden territory. What wasn't out there was a straight port of lazy, suspense and error boundary. The idea would not let me go. So here goes nothing.

Lazy

The idea and execution of React's lazy is simple. Whenever you want to use a component in your code, but you don't want to actually fetch its code yet until it needs to be rendered, wrap it using the lazy() function:
const MarkdownPreview = lazy(() => import('./MarkdownPreview.js'));

React will automatically "suspend" rendering when it first bumps into this lazy component until the component has loaded, and then continue automatically.

This works in React because the markup of a component only looks like HTML, but is actually JavaScript in disguise, better known as JSX. With web components however, the markup that the component is used in is actually HTML, where there is no import() and no calling of functions. That means our vanilla lazy cannot be a JavaScript function, but instead it must be an HTML custom element:
<x-lazy><x-hello-world></x-hello-world></x-lazy>

The basic setup is simple, when the lazy component is added to the DOM, we'll scan for children that have a '-' in the name and therefore are custom elements, see if they're not yet defined, and load and define them if so. By using display: contents we can avoid having the <x-lazy> impact layout.

To actually load the element, we'll have to first find the JS file to import, and then run its register function. By having the function that calls customElements.define as the default export by convention the problem is reduced to finding the path to the JS file. The following code uses a heuristic that assumes components are in a ./components/ subfolder of the current document and follow a consistent file naming scheme:

One could get a lot more creative however, and for example use an import map to map module names to files. This I leave as an exercise for the reader.

Suspense

While the lazy component is loading, we can't show it yet. This is true for custom elements just as much as for React. That means we need a wrapper component that will show a fallback UI as long as any components in its subtree are loading, the <x-suspense> component. This starts out as a tale of two slots. When the suspense element is loading it shows the fallback, otherwise the content.

The trick now is, how to we get loading = true to happen? In Plain Vanilla's applications page I showed how a React context can be simulated using the element.closest() API. We can use the same mechanism to create a generic API that will let our suspense wait on a promise to complete.

Suspense.waitFor will call the nearest ancestor <x-suspense> to a given element, and give it a set of promises that it should wait on. This API can then be called from our <x-lazy> component. Note that #loadElement returns a promise that completes when the custom element is loaded or fails to load.

The nice thing about the promise-based approach is that we can give it any promise, just like we would with React's suspense. For example, when loading data in a custom element that is in the suspense's subtree, we can call the exact same API:
Suspense.waitFor(this, fetch(url).then(...))

Error boundary

Up to this point, we've been assuming everything always works. This is Spartasoftware, it will never "always work". What we need is a graceful way to intercept failed promises that are monitored by the suspense, and show an error message instead. That is the role that React's error boundary plays.

The approach is similar to suspense:

And the code is also quite similar to suspense:

Similar to suspense, this has an API ErrorBoundary.showError() that can be called from anywhere inside the error boundary's subtree to show an error that occurs. The suspense component is then modified to call this API when it bumps into a rejected promise. To hide the error, the reset() method can be called on the error boundary element.

Finally, the error setter will set the error as a property or attribute on all children in the error slot, which enables customizing the error message's behavior based on the error object's properties by creating a custom <x-error-message> component.

Conclusion

Finally, we can bring all of this together in a single example, that combines lazy, suspense, error boundary, a customized error message, and a lazy-loaded hello-world component.

For the complete example's code, as well as the lazy, suspense and error-boundary components, check out the sweet-suspense repo on Github.

================================================ FILE: public/blog/articles/2024-09-09-sweet-suspense/lazy1.js ================================================ customElements.define('x-lazy', class extends HTMLElement { connectedCallback() { this.style.display = 'contents'; this.#loadLazy(); } #loadLazy() { const elements = [...this.children].filter(_ => _.localName.includes('-')); const unregistered = elements.filter(_ => !customElements.get(_.localName)); unregistered.forEach(_ => this.#loadElement(_)); } #loadElement(element) { // TODO: load the custom element } }); ================================================ FILE: public/blog/articles/2024-09-09-sweet-suspense/lazy2-partial.js ================================================ #loadElement(element) { // strip leading x- off the name const cleanName = element.localName.replace(/^x-/, '').toLowerCase(); // assume component is in its own folder const url = `./components/${cleanName}/${cleanName}.js`; // dynamically import, then register if not yet registered return import(new URL(url, document.location)).then(module => !customElements.get(element.localName) && module && module.default()); } ================================================ FILE: public/blog/articles/2024-09-09-sweet-suspense/lazy3-partial.js ================================================ #loadLazy() { const elements = [...this.children].filter(_ => _.localName.includes('-')); const unregistered = elements.filter(_ => !customElements.get(_.localName)); if (unregistered.length) { Suspense.waitFor(this, ...unregistered.map(_ => this.#loadElement(_)) ); } } ================================================ FILE: public/blog/articles/2024-09-09-sweet-suspense/suspense1-partial.html ================================================

Loading...

================================================ FILE: public/blog/articles/2024-09-09-sweet-suspense/suspense1.js ================================================ export class Suspense extends HTMLElement { #fallbackSlot; #contentSlot; set loading(isLoading) { if (!this.#fallbackSlot) return; this.#fallbackSlot.style.display = isLoading ? 'contents' : 'none'; this.#contentSlot.style.display = !isLoading ? 'contents' : 'none'; } constructor() { super(); this.attachShadow({ mode: 'open' }); this.#fallbackSlot = document.createElement('slot'); this.#fallbackSlot.style.display = 'none'; this.#fallbackSlot.name = 'fallback'; this.#contentSlot = document.createElement('slot'); this.shadowRoot.append(this.#fallbackSlot, this.#contentSlot); } connectedCallback() { this.style.display = 'contents'; } } customElements.define('x-suspense', Suspense); ================================================ FILE: public/blog/articles/2024-09-09-sweet-suspense/suspense2-partial.js ================================================ static waitFor(sender, ...promises) { const suspense = sender.closest('x-suspense'); if (suspense) suspense.addPromises(...promises); } addPromises(...promises) { if (!promises.length) return; this.loading = true; // combine into previous promises if there are any const newPromise = this.#waitingForPromise = Promise.allSettled([...promises, this.#waitingForPromise]); // wait for all promises to complete newPromise.then(_ => { // if no newer promises were added, we're done if (newPromise === this.#waitingForPromise) { this.loading = false; } }); } ================================================ FILE: public/blog/articles/2024-09-16-life-and-times-of-a-custom-element/defined/example.html ================================================ defining the custom element

Custom element:

================================================ FILE: public/blog/articles/2024-09-16-life-and-times-of-a-custom-element/defined2/example.html ================================================ defined custom elements

Static element:

Dynamic element:

Dynamic element (in-memory)

Reactions:

================================================ FILE: public/blog/articles/2024-09-16-life-and-times-of-a-custom-element/index.html ================================================ The life and times of a web component A custom element at a house party

The life and times of a web component

When first taught about the wave-particle duality of light most people's brains does a double take. How can light be two different categories of things at the same time, both a wave and a particle? That's just weird. The same thing happens with web components, confusing people when they first try to learn them and run into their Document-JavaScript duality. The component systems in frameworks are typically JavaScript-first, only using the DOM as an outlet for their visual appearance. Web components however — or custom elements to be precise — can start out in either JavaScript or the document, and are married to neither.

Just the DOM please

Do you want to see the minimal JavaScript code needed to set up an <x-example> custom element? Here it is:

 

No, that's not a typo. Custom elements can be used just fine without any JavaScript. Consider this example of an <x-tooltip> custom element that is HTML and CSS only:

For the curious, here is the example.css, but it is not important here.

Such elements are called undefined custom elements. Before custom elements are defined in the window by calling customElements.define() they always start out in this state. There is no need to actually define the custom element if it can be solved in a pure CSS way. In fact, many "pure CSS" components found online can be solved by such custom elements, by styling the element itself and its ::before and ::after pseudo-elements.

A question of definition

The CSS-only representation of the custom element can be progressively enhanced by connecting it up to a JavaScript counterpart, a custom element class. This is a class that inherits from HTMLElement and allows the custom element to implement its own logic.

What happens to the elements already in the markup at the moment customElements.define() is called is an element upgrade. The browser will take all custom elements already in the document, and create an instance of the matching custom element class that it connects them to. This class enables the element to control its own part of the DOM, but also allows it to react to what happens in the DOM.

Element upgrades occur for existing custom elements in the document when customElements.define() is called, and for all new custom elements with that tag name created afterwards (e.g. using document.createElement('x-example')). It does not occur automatically for detached custom elements (not part of the document) that were created before the element was defined. Those can be upgraded retroactively by calling customElements.upgrade().

So far, this is the part of the lifecycle we've seen:

<undefined> 
    -> define() -> <defined>
    -> automatic upgrade() 
                -> constructor() 
                -> <constructed>
        

The constructor as shown in the example above is optional, but if it is specified then it has a number of gotcha's:

It must start with a call to super().
It should not make DOM changes yet, as the element is not yet guaranteed to be connected to the DOM.
This includes reading or modifying its own DOM properties, like its attributes. The tricky part is that in the constructor the element might already be in the DOM, so setting attributes might work. Or it might give an error. It's best to avoid DOM interaction altogether in the constructor.
It should initialize its state, like class properties
But work done in the constructor should be minimized and maximally postponed until connectedCallback.

Making connections

After being constructed, if the element was already in the document, its connectedCallback() handler is called. This handler is normally called only when the element is inserted into the document, but for elements that are already in the document when they are defined it ends up being called as well. In this handler DOM changes can be made, and in the example above the status attribute is set to demonstrate this.

The connectedCallback() handler is part of what is known in the HTML standard as custom element reactions: These reactions allow the element to respond to various changes to the DOM:

  • connectedCallback() is called when the element is inserted into the document, even if it was only moved from a different place in the same document.
  • disconnectedCallback() is called when the element is removed from the document.
  • adoptedCallback() is called when the element is moved to a new document. (You are unlikely to need this in practice.)
  • attributeChangedCallback() is called when an attribute is changed, but only for the attributes listed in its observedAttributes property.

There are also special reactions for form-associated custom elements, but those are a rabbit hole beyond the purview of this blog post.

There are more gotcha's to these reactions:

connectedCallback() and disconnectedCallback() can be called multiple times
This can occur when the element is moved around in the document. These handlers should be written in such a way that it is harmless to run them multiple times, e.g. by doing an early exit when it is detected that connectedCallback() was already run.
attributeChangedCallback() can be called before connectedCallback()
For all attributes already set when the element in the document is upgraded, the attributeChangedCallback() handler will be called first, and only after this connectedCallback() is called. The unpleasant consequence is that any attributeChangedCallback that tries to update DOM structures created in connectedCallback can produce errors.
attributeChangedCallback() is only called for attribute changes, not property changes.
Attribute changes can be done in Javascript by calling element.setAttribute('name', 'value'). DOM attributes and class properties can have the same name, but are not automatically linked. Generally for this reason it is better to avoid having attributes and properties with the same name.

The lifecycle covered up to this point for elements that start out in the initial document:

<undefined> 
    -> define() -> <defined>
    -> automatic upgrade() 
                -> [element].constructor()
                -> [element].attributeChangedCallback()
                -> [element].connectedCallback() 
                -> <connected>
        

Flip the script

So far we've covered one half of the Document-JavaScript duality, for custom elements starting out in the document, and only after that becoming defined and gaining a JavaScript counterpart. It is however also possible to reverse the flow, and start out from JavaScript.

This is the minimal code to create a custom element in JavaScript: document.createElement('x-example'). The element does not need to be defined in order to run this code, although it can be, and the resulting node can be inserted into the document as if it was part of the original HTML markup.

If it is inserted, and after insertion the element becomes defined, then it will behave as described above. Things are however different if the element remains detached:

The detached element will not be automatically upgraded when it is defined.
The constructor or reactions will not be called. It will be automatically upgraded when it is inserted into the document. It can also be upgraded explicitly by calling customElements.upgrade().
If the detached element is already defined when it is created, it will be upgraded automatically.
The constructor() and attributeChangedCallback() will be called. Because it is not yet part of the document connectedCallback() won't be.

By now no doubt you are a bit confused. Here's an interactive playground that lets you test what happens to elements as they go through their lifecycle, both for those in the initial document and those created dynamically.

Here are some interesting things to try out:

  • Create, then Define, and you will see that the created element is not upgraded automatically because it is detached from the document.
  • Create, then Connect, then Define, and you will see that the element is upgraded automatically because it is in the document.
  • Define, then Create, and you will see that the element is upgraded as soon as it is created (constructed appears in the reactions).

I tried writing a flowchart of all possible paths through the lifecycle that can be seen in this example, but it got so unwieldy that I think it's better to just play around with the example until a solid grasp develops.

In the shadows

Adding shadow DOM creates yet another wrinkle in the lifecycle. At any point in the element's JavaScript half, including in its constructor, a shadow DOM can be attached to the element by calling attachShadow(). Because the shadow DOM is immediately available for DOM operations, that makes it possible to do those DOM operations in the constructor.

In this next interactive example you can see what happens when the shadow DOM becomes attached. The x-shadowed element will immediately attach a shadow DOM in its constructor, which happens when the element is upgraded automatically after defining. The x-shadowed-later element postpones adding a shadow DOM until a link is clicked, so the element first starts out as a non-shadowed custom element, and adds a shadow DOM later.

While adding a shadow DOM can be done at any point, it is a one-way operation. Once added the shadow DOM will replace the element's original contents, and this cannot be undone.

Keeping an eye out

So far we've mostly been dealing with initial setup of the custom element, but a major part of the lifecycle is responding to changes as they occur. Here are some of the major ways that custom elements can respond to DOM changes:

  • connectedCallback and disconnectedCallback to handle DOM insert and remove of the element itself.
  • attributeChangedCallback to handle attribute changes of the element.
  • For shadowed custom elements, the slotchange event can be used to detect when children are added and removed in a <slot>.
  • Saving the best for last, MutationObserver can be used to monitor DOM subtree changes, as well as attribute changes.

MutationObserver in particular is worth exploring, because it is a swiss army knife for monitoring the DOM. Here's an example of a counter that automatically updates when new child elements are added:

There is still more to tell, but already I can feel eyes glazing over and brains turning to mush, so I will keep the rest for another day.


Phew, that was a much longer story than I originally set out to write, but custom elements have surprising intricacy. I hope you found it useful, and if not at least you got to see some code and click some buttons. It's all about the clicking of the buttons.

================================================ FILE: public/blog/articles/2024-09-16-life-and-times-of-a-custom-element/observer/example.html ================================================ custom element with observer ================================================ FILE: public/blog/articles/2024-09-16-life-and-times-of-a-custom-element/shadowed/example.html ================================================ shadowed custom element

<x-shadowed>: undefined, not shadowed

<x-shadowed-later>: undefined, not shadowed

================================================ FILE: public/blog/articles/2024-09-16-life-and-times-of-a-custom-element/undefined/example.css ================================================ body { font-family: system-ui, sans-serif; margin: 1em; } button { user-select: none; } /* based on https://github.com/argyleink/gui-challenges/blob/main/tooltips/tool-tip.css */ x-tooltip { --color: lightgray; --bg: hsl(0 0% 20%); pointer-events: none; user-select: none; /* animate in on hover or focus of parent element */ z-index: 1; opacity: 0; transition: opacity .2s ease; transition-delay: 200ms; :is(:hover, :focus-visible, :active) > & { opacity: 1; } /* vertically center and move to the right */ position:absolute; top:50%; transform:translateY(-50%); left: calc(100% + 15px); padding: 0.5em; inline-size: max-content; max-inline-size: 25ch; /* color, backdrop and shadow */ color: var(--color); filter: drop-shadow(0 3px 3px hsl(0 0% 0% / 50%)) drop-shadow(0 12px 12px hsl(0 0% 0% / 50%)); &::after { content: ""; background: var(--bg); position: absolute; z-index: -1; left: 0; top: 50%; transform:translateY(-50%); width: 100%; height: 100%; border-radius: 5px; } /* fix drop shadow in safari */ will-change: filter; } button:has(> x-tooltip) { position: relative; } ================================================ FILE: public/blog/articles/2024-09-16-life-and-times-of-a-custom-element/undefined/example.html ================================================ undefined custom element ================================================ FILE: public/blog/articles/2024-09-28-unreasonable-effectiveness-of-vanilla-js/complete/AddTask.js ================================================ customElements.define('task-add', class extends HTMLElement { connectedCallback() { this.innerHTML = ` `; this.querySelector('button').onclick = () => { const input = this.querySelector('input'); this.closest('tasks-context').dispatch({ type: 'added', id: nextId++, text: input.value }); input.value = ''; }; } }) let nextId = 3; ================================================ FILE: public/blog/articles/2024-09-28-unreasonable-effectiveness-of-vanilla-js/complete/App.js ================================================ customElements.define('tasks-app', class extends HTMLElement { connectedCallback() { this.innerHTML = `

Day off in Kyoto

`; } }); ================================================ FILE: public/blog/articles/2024-09-28-unreasonable-effectiveness-of-vanilla-js/complete/TaskList.js ================================================ customElements.define('task-list', class extends HTMLElement { get context() { return this.closest('tasks-context'); } connectedCallback() { this.context.addEventListener('change', () => this.update()); this.append(document.createElement('ul')); this.update(); } update() { const ul = this.querySelector('ul'); let before = ul.firstChild; this.context.tasks.forEach(task => { let li = ul.querySelector(`:scope > [data-key="${task.id}"]`); if (!li) { li = document.createElement('li'); li.dataset.key = task.id; li.append(document.createElement('task-item')); } li.firstChild.task = task; // move to the right position in the list if not there yet if (li !== before) ul.insertBefore(li, before); before = li.nextSibling; }); // remove unknown nodes while (before) { const remove = before; before = before.nextSibling; ul.removeChild(remove); } } }); customElements.define('task-item', class extends HTMLElement { #isEditing = false; #task; set task(task) { this.#task = task; this.update(); } get context() { return this.closest('tasks-context'); } connectedCallback() { if (this.querySelector('label')) return; this.innerHTML = ` `; this.querySelector('input[type=checkbox]').onchange = e => { this.context.dispatch({ type: 'changed', task: { ...this.#task, done: e.target.checked } }); }; this.querySelector('input[type=text]').onchange = e => { this.context.dispatch({ type: 'changed', task: { ...this.#task, text: e.target.value } }); }; this.querySelector('button#edit').onclick = () => { this.#isEditing = true; this.update(); }; this.querySelector('button#save').onclick = () => { this.#isEditing = false; this.update(); }; this.querySelector('button#delete').onclick = () => { this.context.dispatch({ type: 'deleted', id: this.#task.id }); }; this.context.addEventListener('change', () => this.update()); this.update(); } update() { if (this.isConnected && this.#task) { this.querySelector('input[type=checkbox]').checked = this.#task.done; const inputEdit = this.querySelector('input[type=text]'); inputEdit.style.display = this.#isEditing ? 'inline' : 'none'; inputEdit.value = this.#task.text; const span = this.querySelector('span'); span.style.display = this.#isEditing ? 'none' : 'inline'; span.textContent = this.#task.text; this.querySelector('button#edit').style.display = this.#isEditing ? 'none' : 'inline'; this.querySelector('button#save').style.display = this.#isEditing ? 'inline' : 'none'; } } }); ================================================ FILE: public/blog/articles/2024-09-28-unreasonable-effectiveness-of-vanilla-js/complete/TasksContext.js ================================================ customElements.define('tasks-context', class extends HTMLElement { #tasks = structuredClone(initialTasks); get tasks() { return this.#tasks; } set tasks(tasks) { this.#tasks = tasks; this.dispatchEvent(new Event('change')); } dispatch(action) { this.tasks = tasksReducer(this.tasks, action); } connectedCallback() { this.style.display = 'contents'; } }); function tasksReducer(tasks, action) { switch (action.type) { case 'added': { return [...tasks, { id: action.id, text: action.text, done: false }]; } case 'changed': { return tasks.map(t => { if (t.id === action.task.id) { return action.task; } else { return t; } }); } case 'deleted': { return tasks.filter(t => t.id !== action.id); } default: { throw Error('Unknown action: ' + action.type); } } } const initialTasks = [ { id: 0, text: 'Philosopher’s Path', done: true }, { id: 1, text: 'Visit the temple', done: false }, { id: 2, text: 'Drink matcha', done: false } ]; ================================================ FILE: public/blog/articles/2024-09-28-unreasonable-effectiveness-of-vanilla-js/complete/index.html ================================================ Document
================================================ FILE: public/blog/articles/2024-09-28-unreasonable-effectiveness-of-vanilla-js/complete/index.js ================================================ import './App.js'; import './AddTask.js'; import './TaskList.js'; import './TasksContext.js'; const render = () => { const root = document.getElementById('root'); root.append(document.createElement('tasks-app')); } document.addEventListener('DOMContentLoaded', render); ================================================ FILE: public/blog/articles/2024-09-28-unreasonable-effectiveness-of-vanilla-js/complete/styles.css ================================================ * { box-sizing: border-box; } body { font-family: sans-serif; margin: 20px; padding: 0; } h1 { margin-top: 0; font-size: 22px; } h2 { margin-top: 0; font-size: 20px; } h3 { margin-top: 0; font-size: 18px; } h4 { margin-top: 0; font-size: 16px; } h5 { margin-top: 0; font-size: 14px; } h6 { margin-top: 0; font-size: 12px; } code { font-size: 1.2em; } ul { padding-inline-start: 20px; } button { margin: 5px; } li { list-style-type: none; } ul, li { margin: 0; padding: 0; } ================================================ FILE: public/blog/articles/2024-09-28-unreasonable-effectiveness-of-vanilla-js/index.html ================================================ The unreasonable effectiveness of vanilla JS A female comic book hero bearing a vanilla sigil on her chest

The unreasonable effectiveness of vanilla JS

I have a confession to make. At the end of the Plain Vanilla tutorial's Applications page a challenge was posed to the reader: port react.dev's final example Scaling Up with Reducer and Context to vanilla web code. Here's the confession: until today I had never actually ported over that example myself.

That example demonstrates a cornucopia of React's featureset. Richly interactive UI showing a tasks application, making use of a context to lift the task state up, and a reducer that the UI's controls dispatch to. React's DOM-diffing algorithm gets a real workout because each task in the list can be edited independently from and concurrently with the other tasks. It is an intricate and impressive demonstration. Here it is in its interactive glory:

But I lied. That interactive example is actually the vanilla version and it is identical. If you want to verify that it is in fact identical, check out the original React example. And with that out of the way, let's break apart the vanilla code.

Project setup

The React version has these code files that we will need to port:

  • public/index.html
  • src/styles.css
  • src/index.js: imports the styles, bootstraps React and renders the App component
  • src/App.js: renders the context's TasksProvider containing the AddTask and TaskList components
  • src/AddTask.js: renders the simple form at the top to add a new task
  • src/TaskList.js: renders the list of tasks

To make things fun, I chose the same set of files with the same filenames for the vanilla version. Here's index.html:

The only real difference is that it links to index.js and styles.css. The stylesheet was copied verbatim, but for the curious here's a link to styles.css.

Get to the code

index.js is where it starts to get interesting. Compare the React version to the vanilla version:

Bootstrapping is different but also similar. All of the web components are imported first to load them, and then the <tasks-app> component is rendered to the page.

The App.js code also bears more than a striking resemblance:

What I like about the code so far is that it feels React-like. I generally find programming against React's API pleasing, but I don't like the tooling, page weight and overall complexity baggage that it comes with.

Adding context

The broad outline of how to bring a React-like context to a vanilla web application is already explained in the passing data deeply section of the main Plain Vanilla tutorial, so I won't cover that again here. What adds spice in this specific case is that the React context uses a reducer, a function that accepts the old tasks and an action to apply to them, and returns the new tasks to show throughout the application.

Thankfully, the React example's reducer function and initial state were already vanilla JS code, so those come along for the ride unchanged and ultimately the vanilla context is a very straightforward custom element:

The actual context component is very bare bones, as it only needs to store the tasks, emit change events for the other components to subscribe to, and provide a dispatch method for those components to call that will use the reducer function to update the tasks.

Adding tasks

The AddTask component ends up offering more of a challenge. It's a stateful component with event listeners that dispatches to the reducer:

The main wrinkle this adds for the vanilla web component is that the event listener on the button element cannot be put inline with the markup. Luckily the handling of the input is much simplified because we can rely on it keeping its state automatically, a convenience owed to not using a virtual DOM. Thanks to the groundwork in the context component the actual dispatching of the action is easy:

Fascinating to me is that index.js, App.js, TasksContext.js and AddTask.js are all fewer lines of code in the vanilla version than their React counterpart while remaining functionally equivalent.

Hard mode

The TaskList component is where React starts really pulling its weight. The React version is clean and straightforward and juggles a lot of state with a constantly updating task list UI.

This proved to be a real challenge to port. The vanilla version ended up being a lot more verbose because it has to do all the same DOM-reconciliation in explicit logic managed by the update() methods of <task-list> and <task-item>.

Some interesting take-aways:

  • The <task-list> component's update() method implements a poor man's version of React reconciliation, merging the current state of the tasks array into the child nodes of the <ul>. In order to do this, it has to store a key on each list item, just like React requires, and here it becomes obvious why that is. Without the key we can't find the existing <li> nodes that match up to task items, and so would have to recreate the entire list. By adding the key it becomes possible to update the list in-place, modifying task items instead of recreating them so that they can keep their on-going edit state.
  • That reconciliation code is very generic however, and it is easy to imagine a fully generic repeat() function that converts an array of data to markup on the page. In fact, the Lit framework contains exactly that. For brevity's sake this code doesn't go quite that far.
  • The <task-item> component cannot do what the React code does: create different markup depending on the current state. Instead it creates the union of the markup across the various states, and then in the update() shows the right subset of elements based on the current state.

That wraps up the entire code. You can find the ported example on Github.

Some thoughts

A peculiar result of this porting challenge is that the vanilla version ends up being roughly the same number of lines of code as the React version. The React code is still overall less verbose (all those querySelectors, oy!), but it has its own share of boilerplate that disappears in the vanilla version. This isn't a diss against React, it's more of a compliment to how capable browsers have gotten that vanilla web components can carry us so far.

If I could have waved a magic wand, what would have made the vanilla version simpler?

  • All of those querySelector calls get annoying. The alternatives are building the markup easily with innerHTML and then fishing out references to the created elements using querySelector, or building the elements one by one verbosely using createElement, but then easily having a reference to them. Either of those ends up very verbose. An alternative templating approach that makes it easy to create elements and get a reference to them would be very welcome.
  • As long as we're dreaming, I'm jealous of how easy it is to add the event listeners in JSX. A real expression language in HTML templates that supports data and event binding and data-conditional markup would be very neat and would take away most of the reason to still find a framework's templating language more convenient. Web components are a perfectly fine alternative to React components, they just lack an easy built-in templating mechanism.
  • Browsers could get a little smarter about how they handle DOM updates during event handling. In the logic that sorts the <li> to the right order in the list, the if condition before insertBefore proved necessary because the browser didn't notice that the element was already placed where it needed to be inserted, and click events would get lost as a consequence. I've even noticed that assigning a textContent to a button mid-click will make Safari lose track of that button's click event. All of that can be worked around with clever reconciliation logic, but that's code that belongs in the browser, not in JavaScript.

All in all though, I'm really impressed with vanilla JS. I call it unreasonably effective because it is jarring just how capable the built-in abilities of browsers are, and just how many web developers despite that still default to web frameworks for every new project. Maybe one day...

================================================ FILE: public/blog/articles/2024-09-28-unreasonable-effectiveness-of-vanilla-js/react/package.json ================================================ { "name": "react.dev", "version": "0.0.0", "main": "/src/index.js", "scripts": { "start": "react-scripts start", "build": "react-scripts build", "test": "react-scripts test --env=jsdom", "eject": "react-scripts eject" }, "dependencies": { "react": "^18.0.0", "react-dom": "^18.0.0", "react-scripts": "^5.0.0" }, "devDependencies": {} } ================================================ FILE: public/blog/articles/2024-09-28-unreasonable-effectiveness-of-vanilla-js/react/public/index.html ================================================ Document
================================================ FILE: public/blog/articles/2024-09-28-unreasonable-effectiveness-of-vanilla-js/react/src/AddTask.js ================================================ import { useState } from 'react'; import { useTasksDispatch } from './TasksContext.js'; export default function AddTask() { const [text, setText] = useState(''); const dispatch = useTasksDispatch(); return ( <> setText(e.target.value)} /> ); } let nextId = 3; ================================================ FILE: public/blog/articles/2024-09-28-unreasonable-effectiveness-of-vanilla-js/react/src/App.js ================================================ import AddTask from './AddTask.js'; import TaskList from './TaskList.js'; import { TasksProvider } from './TasksContext.js'; export default function TaskApp() { return (

Day off in Kyoto

); } ================================================ FILE: public/blog/articles/2024-09-28-unreasonable-effectiveness-of-vanilla-js/react/src/TaskList.js ================================================ import { useState } from 'react'; import { useTasks, useTasksDispatch } from './TasksContext.js'; export default function TaskList() { const tasks = useTasks(); return ( ); } function Task({ task }) { const [isEditing, setIsEditing] = useState(false); const dispatch = useTasksDispatch(); let taskContent; if (isEditing) { taskContent = ( <> { dispatch({ type: 'changed', task: { ...task, text: e.target.value } }); }} /> ); } else { taskContent = ( <> {task.text} ); } return ( ); } ================================================ FILE: public/blog/articles/2024-09-28-unreasonable-effectiveness-of-vanilla-js/react/src/TasksContext.js ================================================ import { createContext, useContext, useReducer } from 'react'; const TasksContext = createContext(null); const TasksDispatchContext = createContext(null); export function TasksProvider({ children }) { const [tasks, dispatch] = useReducer( tasksReducer, initialTasks ); return ( {children} ); } export function useTasks() { return useContext(TasksContext); } export function useTasksDispatch() { return useContext(TasksDispatchContext); } function tasksReducer(tasks, action) { switch (action.type) { case 'added': { return [...tasks, { id: action.id, text: action.text, done: false }]; } case 'changed': { return tasks.map(t => { if (t.id === action.task.id) { return action.task; } else { return t; } }); } case 'deleted': { return tasks.filter(t => t.id !== action.id); } default: { throw Error('Unknown action: ' + action.type); } } } const initialTasks = [ { id: 0, text: 'Philosopher’s Path', done: true }, { id: 1, text: 'Visit the temple', done: false }, { id: 2, text: 'Drink matcha', done: false } ]; ================================================ FILE: public/blog/articles/2024-09-28-unreasonable-effectiveness-of-vanilla-js/react/src/index.js ================================================ import React, { StrictMode } from "react"; import { createRoot } from "react-dom/client"; import "./styles.css"; import App from "./App"; const root = createRoot(document.getElementById("root")); root.render( ); ================================================ FILE: public/blog/articles/2024-09-28-unreasonable-effectiveness-of-vanilla-js/react/src/styles.css ================================================ * { box-sizing: border-box; } body { font-family: sans-serif; margin: 20px; padding: 0; } h1 { margin-top: 0; font-size: 22px; } h2 { margin-top: 0; font-size: 20px; } h3 { margin-top: 0; font-size: 18px; } h4 { margin-top: 0; font-size: 16px; } h5 { margin-top: 0; font-size: 14px; } h6 { margin-top: 0; font-size: 12px; } code { font-size: 1.2em; } ul { padding-inline-start: 20px; } button { margin: 5px; } li { list-style-type: none; } ul, li { margin: 0; padding: 0; } ================================================ FILE: public/blog/articles/2024-09-30-lived-experience/index.html ================================================ Lived experience An old man sitting astride a tall pile of books

Lived experience

Ryan Carniato shared a hot take a few days ago, Web Components Are Not the Future. As hot takes tend to do, it got some responses, like Nolan Lawson's piece Web components are okay, or Cory LaViska's Web Components Are Not the Future — They're the Present. They do an excellent job of directly engaging Ryan's arguments, so I'm not going to do that here. Instead I want to talk about my lived experience of web development, and where I hope it is headed in the future. Take it in the spirit it is intended, one of optimism and possibility.

A galaxy far, far away

So I've been making web sites since a long time ago, since before CSS and HTML4. I say this not to humblebrag, but to explain that I was here for all of it. Every time when the definition of modern web development changed I would update my priors, following along.

For the longest time making web pages do anything except show text and images was an exercise in frustration. Browsers were severely lacking in features, were wildly incompatible with web standards and each other, and the tools that web developers needed to bring them to order were missing or lacking. I built my share of vanilla JS components back in the IE6 days, and it made me dream of a better way. When frameworks first started coming on the scene with that better way, adding missing features, abstracting away incompatibility, and providing better tooling, I was ready for them. I was all-in.

I bought into ExtJS, loved it for a time, and then got a hundred thousand line codebase stuck on ExtJS 3 because version 4 changed things so much that porting was too costly. I then bought into Backbone, loved that too, but had to move on when its principal developer did. I joined a team that bought into AngularJS and got stuck painted into a corner when the Angular team went in a totally different direction for v2. I helped rewrite a bunch of frontends in Angular v2 and React, and found myself sucked into constant forced maintenance when their architecture and ecosystems churned.

Did I make bad choices? Even in hindsight I would say I picked the right choices for the time. Time just moved on.

The cost of change

This lived experience taught me a strong awareness of rates of change in dependencies, and the costs they impose. I imagine a web page as a thin old man sitting astride a tall pile of dependencies, each changing at their own pace. Some dependencies are stable for decades, like HTML's core set of elements, or CSS 2's set of layout primitives. They're so stable that we don't even consider them dependencies, they're just the web.

Other dependencies change every few years, like module systems, or new transpiled languages, or the preferred build and bundling tool of the day, or what framework is in vogue. Then there are the dependencies that change yearly, like major framework and OS releases. Finally there are the dependencies that change constantly, like the many packages that contribute to a typical web application built with a popular framework.

As a web developer who loves their user, taking on those dependencies creates a Solomon's choice. Either you keep up with the churn, and spend a not insignificant amount of your day working and reworking code that already works, instead of working on the things your user cares about. Or, you stick it out for as long as you can on old versions, applying ever more workarounds to get old framework releases and their outdated build and CLI tools to work in new OS and ecosystem environments, slowly boiling a frog that will at some point force a deep rewrite, again at the expense of the user.

Which is not to say the frameworks don't add value. They absolutely do, and they keep getting better. Writing new code on a new framework is a steadily rising tide of developer experience. But let us not pretend these benefits don't come at a cost. Wherever there is a codebase too complicated to understand and maintain yourself, wherever there is a set of build tools that must be kept compatible with changes in operating systems and ecosystems, there is a shelf life. Sooner or later the makers of every framework and of every tool will move on, even if it's just to a new long-term supported release, and the web developers that they served will have to move with them.

I hold this truth to be self-evident: the larger the abstraction layer a web developer uses on top of web standards, the shorter the shelf life of their codebase becomes, and the more they will feel the churn.

The rising tide

Why do modern web projects built with modern frameworks depend on so much stuff? At first there was no other option. Interacting with the DOM was painful, and web frameworks rightly made choices to keep component systems outside the DOM, minimizing and abstracting away those interactions in increasingly clever DOM reconciliation strategies. Supporting the brittle browsers and slow devices of the day required many workarounds and polyfills, and web frameworks rightly added intricate tools to build, bundle and minify the user's code.

They needed a way to bring dependencies into those build systems, and sanely settled on the convention of node modules and the NPM ecosystem. It got easy to add more dependencies, and just as water always finds the easy way down, dependencies found the easy way in. As the abstraction layer grew the load time cost imposed by it grew right along, and so we got server-side rendering, client-side hydration, lazy loading, and many other load time reduction strategies.

DOM-diffing, synthetic event systems, functional components, JSX, reactive data layers, server-side rendering and streaming, bundlers, tree shaking, transpilers and compilers, and all the other complications that you won't find in web standards but you will find in every major web framework — they are the things invented to make the modern web possible, but they are not the web. The web is what ships to the browser. And all of those things are downstream from the decision to abstract away the browser, a decision once made in good faith and for good reasons. A decision which now needs revisiting.

Browsers were not standing still. They saw what web developers were doing in userland to compensate for the deficiencies in browser API's, and they kept improving and growing the platform, a rising tide slowly catching up to what frameworks did. When Microsoft bid IE a well-deserved farewell on June 15, 2022 a tipping point was reached. For the first time the browser platform was so capable that it felt to me like it didn't need so much abstracting away anymore. It wasn't a great platform, not as cleanly designed or complete as the API's of the popular frameworks, but it was Good Enough™ as a foundation, and that was all that mattered.

Holding my breath

I was very excited for what would happen in the framework ecosystem. There was a golden opportunity for frameworks to tear down their abstraction layers, make something far simpler, far more closely aligned with the base web platform. They could have a component system built on top of web components, leveraging browser events and built-in DOM API's. All the frameworks could become cross-compatible, easily plugging into each other's data layers and components while preserving what makes them unique. The page weights would shrink by an order of magnitude with so much infrastructure code removed, and that in combination with the move to HTTP/3 could make build tools optional. It would do less, so inevitably be worse in some ways, but sometimes worse is better.

I gave a talk about how good the browser's platform had gotten, showing off a version of Create React App that didn't need any build tools and was extremely light-weight, and the developer audience was just as excited as I was. And I held my breath waiting on framework churn to for once go in the other direction, towards simplicity...

But nothing happened. In fact, the major frameworks kept building up their abstraction layers instead of building down. We got React Server Components and React Compiler, exceedingly clever, utterly incomprehensible, workarounds for self-imposed problems caused by overengineering. Web developers don't seem to mind, but they struggle quietly with how to keep up with these tools and deliver good user experiences. The bigger the abstraction layer gets, the more they feel the churn.

The irony is not lost on me that now the framework authors also feel the churn in their dependencies, struggling to adapt to web components as foundational technology. React 19 is supposed to finally support web components in a way that isn't incredibly painful, or so they say, we'll see. I confess to feeling some satisfaction in their struggle. The shoe is on the other foot. Welcome to modern web development.

The road ahead

What the frameworks are doing, that's fine for them, and they can keep doing it. But I'm done with all that unless someone is paying me to do it. They're on a fundamentally different path from where I want web development to go, from how I want to make web pages. The web is what ships to the browser. Reducing the distance between what the developer writes and what ships to the browser is valuable and necessary. This blog and this site are my own stake in the ground for this idea, showing just how much you can get done without any framework code or build tools at all. But let's be honest: web components are not a framework, no matter how hard I tried to explain them as one.

Comparing web components to React is like comparing a good bicycle with a cybertruck.

They do very different things, and they're used by different people with very, very different mindsets.

Jeremy Keith

I want a motorbike, not a cybertruck. I still want frameworks, only much lighter. Frameworks less than 10 KB in size, that are a thin layer on top of web standards but still solve the problems that frameworks solve. I call this idea the interoperable web framework:

  • Its components are just web components.
  • Its events are just DOM events.
  • Its templates are just HTML templates.
  • It doesn't need to own the DOM that its components take part in.
  • Its data and event binding works on all HTML elements, built-in or custom, made with the framework or with something else.
  • It can be easily mixed together on a page with other interoperable web frameworks, with older versions of itself, or with vanilla code.
  • It doesn't need its own build tools.

I just feel it on my bones such a thing can be built now. Maybe I'm wrong and Ryan Carniato is right. After all, he knows a lot more about frameworks than I do. But the more vanilla code that I write the more certain that I feel on this. Some existing solutions like Lit are close, but none are precisely what I am looking for. I would love to see a community of vanilla developers come together to figure out what that could look like, running experiments and iterating on the results. For now I will just keep holding my breath, waiting for the tide to come in.

================================================ FILE: public/blog/articles/2024-10-07-needs-more-context/combined/context-provider.js ================================================ export class ContextProvider extends EventTarget { #value; get value() { return this.#value } set value(v) { this.#value = v; this.dispatchEvent(new Event('change')); } #context; get context() { return this.#context } constructor(target, context, initialValue = undefined) { super(); this.#context = context; this.#value = initialValue; this.handle = this.handle.bind(this); if (target) this.attach(target); } attach(target) { target.addEventListener('context-request', this.handle); } detach(target) { target.removeEventListener('context-request', this.handle); } /** * Handle a context-request event * @param {ContextRequestEvent} e */ handle(e) { if (e.context === this.context) { if (e.subscribe) { const unsubscribe = () => this.removeEventListener('change', update); const update = () => e.callback(this.value, unsubscribe); this.addEventListener('change', update); update(); } else { e.callback(this.value); } e.stopPropagation(); } } } ================================================ FILE: public/blog/articles/2024-10-07-needs-more-context/combined/context-request.js ================================================ export class ContextRequestEvent extends Event { constructor(context, callback, subscribe) { super('context-request', { bubbles: true, composed: true, }); this.context = context; this.callback = callback; this.subscribe = subscribe; } } ================================================ FILE: public/blog/articles/2024-10-07-needs-more-context/combined/index.css ================================================ body { margin: 1em; font-family: system-ui, sans-serif; } theme-panel { display: block; border: 1px dotted gray; min-height: 2em; max-width: 400px; padding: 1em; margin-bottom: 1em; } .panel-light { color: #222; background: #fff; } .panel-dark { color: #fff; background: rgb(23, 32, 42); } theme-toggle button { margin: 0; padding: 5px; } .button-light, .button-dark { border: 1px solid #777; } .button-dark { background: #222; color: #fff; } .button-light { background: #fff; color: #222; } ================================================ FILE: public/blog/articles/2024-10-07-needs-more-context/combined/index.html ================================================ tiny-context example ================================================ FILE: public/blog/articles/2024-10-07-needs-more-context/combined/index.js ================================================ import { ContextRequestEvent } from "./context-request.js"; import "./theme-provider.js"; // global provider on body import "./theme-context.js"; // element with local provider customElements.define('theme-demo', class extends HTMLElement { connectedCallback() { this.innerHTML = ` `; this.querySelector('button').onclick = reparent; } }); customElements.define('theme-panel', class extends HTMLElement { #unsubscribe; connectedCallback() { this.dispatchEvent(new ContextRequestEvent('theme', (theme, unsubscribe) => { this.className = 'panel-' + theme; this.#unsubscribe = unsubscribe; }, true)); } disconnectedCallback() { this.#unsubscribe?.(); } }); customElements.define('theme-toggle', class extends HTMLElement { #unsubscribe; connectedCallback() { this.innerHTML = ''; this.dispatchEvent(new ContextRequestEvent('theme-toggle', (toggle) => { this.querySelector('button').onclick = toggle; })); this.dispatchEvent(new ContextRequestEvent('theme', (theme, unsubscribe) => { this.querySelector('button').className = 'button-' + theme; this.#unsubscribe = unsubscribe; }, true)); } disconnectedCallback() { this.#unsubscribe?.(); } }); function reparent() { const toggle = document.querySelector('theme-toggle'); const first = document.querySelector('theme-panel#first'); const second = document.querySelector('theme-panel#second'); if (toggle.parentNode === second) { first.append(toggle); } else { second.append(toggle); } } ================================================ FILE: public/blog/articles/2024-10-07-needs-more-context/combined/theme-context.js ================================================ import { ContextProvider } from "./context-provider.js"; customElements.define('theme-context', class extends HTMLElement { themeProvider = new ContextProvider(this, 'theme', 'light'); toggleProvider = new ContextProvider(this, 'theme-toggle', () => { this.themeProvider.value = this.themeProvider.value === 'light' ? 'dark' : 'light'; }); connectedCallback() { this.style.display = 'contents'; } }); ================================================ FILE: public/blog/articles/2024-10-07-needs-more-context/combined/theme-provider.js ================================================ // loaded with import { ContextProvider } from "./context-provider.js"; const themeProvider = new ContextProvider(document.body, 'theme', 'light'); const toggleProvider = new ContextProvider(document.body, 'theme-toggle', () => { themeProvider.value = themeProvider.value === 'light' ? 'dark' : 'light'; }); ================================================ FILE: public/blog/articles/2024-10-07-needs-more-context/context-provider.js ================================================ export class ContextProvider extends EventTarget { #value; get value() { return this.#value } set value(v) { this.#value = v; this.dispatchEvent(new Event('change')); } #context; get context() { return this.#context } constructor(target, context, initialValue = undefined) { super(); this.#context = context; this.#value = initialValue; this.handle = this.handle.bind(this); if (target) this.attach(target); } attach(target) { target.addEventListener('context-request', this.handle); } detach(target) { target.removeEventListener('context-request', this.handle); } /** * Handle a context-request event * @param {ContextRequestEvent} e */ handle(e) { if (e.context === this.context) { if (e.subscribe) { const unsubscribe = () => this.removeEventListener('change', update); const update = () => e.callback(this.value, unsubscribe); this.addEventListener('change', update); update(); } else { e.callback(this.value); } e.stopPropagation(); } } } ================================================ FILE: public/blog/articles/2024-10-07-needs-more-context/context-request-1.js ================================================ class ContextRequestEvent extends Event { constructor(context, callback, subscribe) { super('context-request', { bubbles: true, composed: true, }); this.context = context; this.callback = callback; this.subscribe = subscribe; } } customElements.define('my-component', class extends HTMLElement { connectedCallback() { this.dispatchEvent( new ContextRequestEvent('theme', (theme) => { // ... }) ); } }); ================================================ FILE: public/blog/articles/2024-10-07-needs-more-context/context-request-2.js ================================================ customElements.define('my-component', class extends HTMLElement { connectedCallback() { let theme = 'light'; // default value this.dispatchEvent( new ContextRequestEvent('theme', t => theme = t) ); // do something with theme } }); ================================================ FILE: public/blog/articles/2024-10-07-needs-more-context/context-request-3.js ================================================ customElements.define('my-component', class extends HTMLElement { #unsubscribe; connectedCallback() { this.dispatchEvent( new ContextRequestEvent('theme', (theme, unsubscribe) => { this.#unsubscribe = unsubscribe; // do something with theme }, true) ); } disconnectedCallback() { this.#unsubscribe?.(); } }); ================================================ FILE: public/blog/articles/2024-10-07-needs-more-context/context-request-4.js ================================================ customElements.define('my-component', class extends HTMLElement { connectedCallback() { let theme = 'light'; this.dispatchEvent( new ContextRequestEvent('theme', (t, unsubscribe) => { theme = t; unsubscribe?.(); }) ); // do something with theme } }); ================================================ FILE: public/blog/articles/2024-10-07-needs-more-context/index.html ================================================ Needs more context A comic book page showing a cowbell being rang

Needs more context

In the earlier article the unreasonable effectiveness of vanilla JS I explained a vanilla web version of the React tutorial example Scaling up with Reducer and Context. That example used a technique for context based on Element.closest(). While that way of obtaining a context is very simple, which definitely has its merits, it also has some downsides:

  • It cannot be used from inside a shadow DOM to find a context that lives outside of it without clumsy workarounds.
  • It requires a custom element to be the context.
  • There has to be separate mechanism to subscribe to context updates.

There is in fact, or so I learned recently, a better and more standard way to solve this known as the context protocol. It's not a browser feature, but a protocol for how to implement a context in web components.

This is how it works: the consumer starts by dispatching a context-request event.

The event will travel up the DOM tree (bubbles = true), piercing any shadow DOM boundaries (composed = true), until it reaches a listener that responds to it. This listener is attached to the DOM by a context provider. The context provider uses the e.context property to detect whether it should respond, then calls e.callback with the appropriate context value. Finally it calls e.stopPropagation() so the event will stop bubbling up the DOM tree.

This whole song and dance is guaranteed to happen synchronously, which enables this elegant pattern:

If no provider is registered the event's callback is never called and the default value will be used instead.

Instead of doing a one-off request for a context's value it's also possible to subscribe to updates by setting its subscribe property to true. Every time the context's value changes the callback will be called again. To ensure proper cleanup the subscribing element has to unsubscribe on disconnect.

It is recommended, but not required, to listen for and call unsubscribe functions in one-off requests, just in case a provider is overzealously creating subscriptions. However, this is not necessary when using only spec-compliant providers.

Providers are somewhat more involved to implement. There are several spec-compliant libraries that implement them, like @lit/context and wc-context. A very minimal implementation is this one:

This minimal provider can then be used in a custom element like this:

Which would be used on a page like this, with <my-subscriber> requesting the theme by dispatching a context-request event.

Notice in the above example that the theme-toggle context is providing a function. This unlocks a capability for dependency injection where API's to control page behavior are provided by a context to any subscribing custom element.

Don't let this example mislead you however. A provider doesn't actually need a dedicated custom element, and can be attached to any DOM node, even the body element itself. This means a context can be provided or consumed from anywhere on the page.

And because there can be more than one event listener on a page, there can be more than one provider providing the same context. The first one to handle the event will win.

Here's an example that illustrates a combination of a global provider attached to the body (top panel), and a local provider using a <theme-context> (bottom panel). Every time the <theme-toggle> is reparented it resubscribes to the theme from the nearest provider.

The full implementation of this protocol can be found in the tiny-context repo on Github.

================================================ FILE: public/blog/articles/2024-10-07-needs-more-context/theme-context-fragment.html ================================================
================================================ FILE: public/blog/articles/2024-10-07-needs-more-context/theme-context.js ================================================ customElements.define('theme-context', class extends HTMLElement { themeProvider = new ContextProvider(this, 'theme', 'light'); toggleProvider = new ContextProvider(this, 'theme-toggle', () => { this.themeProvider.value = this.themeProvider.value === 'light' ? 'dark' : 'light'; }); connectedCallback() { this.style.display = 'contents'; } }); ================================================ FILE: public/blog/articles/2024-10-07-needs-more-context/theme-provider.js ================================================ // loaded with import { ContextProvider } from "./context-provider.js"; const themeProvider = new ContextProvider(document.body, 'theme', 'light'); const toggleProvider = new ContextProvider(document.body, 'theme-toggle', () => { themeProvider.value = themeProvider.value === 'light' ? 'dark' : 'light'; }); ================================================ FILE: public/blog/articles/2024-10-20-editing-plain-vanilla/.hintrc ================================================ { "extends": [ "development" ], "hints": { "compat-api/html": [ "default", { "ignore": [ "iframe[loading]" ] } ], "no-inline-styles": "off" } } ================================================ FILE: public/blog/articles/2024-10-20-editing-plain-vanilla/eslint.config.cjs ================================================ /* eslint-disable no-undef */ const globals = require("globals"); const js = require("@eslint/js"); module.exports = [ js.configs.recommended, { languageOptions: { globals: { ...globals.browser, ...globals.mocha }, ecmaVersion: 2022, sourceType: "module", } }, { ignores: [ "public/blog/articles/", "**/lib/", "**/react/", ] } ]; ================================================ FILE: public/blog/articles/2024-10-20-editing-plain-vanilla/index.html ================================================ Editing Plain Vanilla Early 20th century, an editor laying out a newspaper by hand

Editing Plain Vanilla

I'm typing this up in Visual Studio Code, after realizing I should probably explain how I use it to make this site. But the whole idea behind Plain Vanilla is no build, no framework, no tools. And VS Code is definitely a tool. So what gives?

There's a difference between tools that sit in between the code and a deployed site, and tools that only act in support of editing or deploying. The first category, like npm or typescript, impose continued maintenance on the project because they form a direct dependency. The second category, like VS Code or git, are easily replaced without impacting the project's ability to be edited. There's a tension between the ability to get things done faster, and the burden created by additional dependencies. For this project I draw the line in accepting the second category while rejecting the first.

Setting up a profile

I use VS Code for framework-based work projects as well as vanilla web development. To keep those two realms neatly separated I've set up a separate Vanilla profile. In that profile is a much leaner suite of extensions, configured for only vanilla web development.

  • ESLint, to automatically lint the JS code while editing
  • webhint, to lint the HTML and CSS code, and detect accessibility issues
  • html-in-template-string, to syntax highlight HTML template strings
  • Todo Tree, to keep track of TODO's while doing larger changes
  • Live Preview, to get a live preview of the page that I'm working on
  • VS Code Counter, for quick comparative line counts when porting framework code to vanilla
  • Intellicode, for simple code completion
  • Codeium, for AI-assisted code completion, works great for web component boilerplate

Modern web development can be very overburdened by tools, sometimes all but requiring the latest Macbook Pro with decadent amounts of RAM just to edit a basic project. The combination of a no build plain vanilla codebase with a lean VS Code profile guarantees quick editing, even on my oldest and slowest laptops.

Linting

Nobody's perfect, myself included, so something needs to be scanning the code for goofs. The first linting tool that's set up is ESLint. The VS Code extension regrettably does not come bundled with an eslint installation, so this has to be installed explicitly. By doing it once globally this can be reused across vanilla web projects.

npm install -g eslint @eslint/js globals

Because I use nvm to manage node versions the global eslint install was not automatically detectable. This required setting a NODE_PATH in .zshrc that VS Code then picked up.

export NODE_PATH=$(npm root -g)

In addition, in order to lint successfully it needs a configuration file, located in the project's root.

Setting the ecmaVersion to 2022 ensures that I don't accidentally use newer and unsupported Javascript features, like in this example trying to use ES2024's v flag in regular expressions. This version could be set to whatever browser compatibility a project requires.

ESLint error for ECMAScript 2024 feature

The ignores blocks excludes external libraries to placate my OCD that wants to see zero errors or warnings reported by eslint project-wide. The article folders are excluded for a similar reason, because they contain a lot of incomplete and deliberately invalid example JS files.

The webhint extension is installed to do automatic linting on HTML and CSS. Luckily it out of the box comes bundled with a webhint installation and applies the default development ruleset. A nice thing about this extension is that it reports accessibility issues.

Webhint error for accessibility

Only a few tweaks were made to the webhint configuration to again get to that all-important zero warnings count.

html-in-template-string

I've mentioned it before in the entity encoding article, but this neat little extension formats HTML inside tagged template strings in web component JS code.

Syntax highlighting in template strings

Live Preview

The center piece for a smooth editing workflow is the Live Preview extension. I can right-click on any HTML file in the project and select "Show Preview" to get a live preview of the page. This preview automatically refreshes when files are saved. Because vanilla web pages always load instantly this provides the hot-reloading workflow from framework projects, except even faster and with zero setup. The only gotcha is that all paths in the site have to be relative paths so the previewed page can resolve them.

Live Preview of this article while editing

The preview's URL can be pasted into a "real browser" to debug tricky javascript issues and do compatibility testing. Very occasionally I'll need to spin up a "real server", but most of the code in my vanilla projects is written with only a Live Preview tab open.

Previewing is also how I get a realtime view of unit tests while working on components or infrastructure code, by opening the tests web page and selecting the right test suite to hot reload while editing.

Bonus: working offline

Because I live at the beach and work in the big city I regularly need to take long train rides with spotty internet connection. Like many developers I cannot keep web API's in my head and have to look them up regularly while coding. To have a complete offline vanilla web development setup with me at all times I use devdocs.io. All my projects folders are set up to sync automatically with Syncthing, so whatever I was doing on the desktop can usually be smoothly continued offline on the laptop without having to do any prep work to make that possible.

devdocs.io screenshot

There, that's my lean vanilla web development setup. What should I add to this, or do differently? Feel free to let me know.

================================================ FILE: public/blog/articles/2024-12-16-caching-vanilla-sites/index.html ================================================ Caching vanilla sites Early 20th century, an editor laying out a newspaper by hand

Caching vanilla sites

If you go to a typical website built with a framework, you'll see a lot of this:

browser devtools showing network requests for vercel.com

Those long cryptic filenames are not meant to discourage casual snooping. They're meant to ensure the filename is changed every time a single byte in that file changes, because the site is using far-future expire headers, a technique where the browser is told to cache files indefinitely, until the end of time. On successive page loads those resources will then always be served from cache. The only drawback is having to change the filename each time the file's contents change, but a framework's build steps typically take care of that.

For vanilla web sites, this strategy doesn't work. By abandoning a build step there is no way to automatically generate filenames, and unless nothing makes you happier than renaming all files manually every time you deploy a new version, we have to look towards other strategies.

How caching works

Browser cache behavior is complicated, and a deep dive into the topic deserves its own article. However, very simply put, what you'll see is mostly these response headers:

Cache-Control

The cache control response header determines whether the browser should cache the response, and how long it should serve the response from cache.

Cache-Control: public, max-age: 604800

This will cache the resource and only check if there's a new version after one week.

Age

The max-age directive does not measure age from the time that the response is received, but from the time that the response was originally served:

Age: 10

This response header indicates the response was served on the origin server 10 seconds ago.

Etag

The Etag header is a unique hash of the resource's contents, an identifier for that version of the resource.

ETag: "33a64df551425fcc55e4d42a148795d9f25f89d4"

When the browser requests that resource again from the server, knowing the Etag it can pass an If-None-Match header with the Etag's value. If the resource has not changed it will still have the same Etag value, and the server will respond with 304 Not Modified.

If-None-Match: "33a64df551425fcc55e4d42a148795d9f25f89d4"

Last-Modified

The Last-Modified header works similarly to Etag, except instead of sending a hash of the contents, it sends a timestamp of when the resource was last changed. Like Etag's If-None-Match it is matched by the If-Modified-Since header when requesting the resource from the server again.

With that basic review of caching headers, let's look at some strategies for making good use of them in vanilla web projects.

Keeping it simple: GitHub Pages

The simplest strategy is what GitHub Pages does: cache files for 10 minutes. Every file that's downloaded has Cache-Control: max-age headers that make it expire 10 minutes into the future. After that if the file is loaded again it will be requested from the network. The browser will add If-None-Match or If-Modified-Since headers to allow the server to avoid sending the file if it hasn't been changed, saving bytes but not a roundtrip.

If you want to see it in action, just open the browser devtools and reload this page.

browser devtools showing network requests for plainvanillaweb.com

Visitors never get a page that is more than 10 minutes out of date, and as they navigate around the site they mostly get fast cache-served responses. However, on repeat visits they will get a slow first-load experience. Also, if the server updates in the middle of a page load then different resources may end up mismatched and belong to a different version of the site, causing unpredictable bugs. Well, for 10 minutes at least.

Extending cache durations

While the 10 minute cache policy is ok for HTML content and small JS and CSS files, it can be improved by increasing cache times on large resources like libraries and images. By using a caching proxy that allows setting rules on specific types or folders of files we can increase the cache duration. For sites proxied through Cloudflare, their cache customization settings can be used to set these resource-specific policies.

By setting longer cache durations on some resources, we can ensure they're served from local cache more often. However, what to do if the resource changes? In those cases we need to modify the fetched URL of the resource every place that it is referred to. For example, by appending a unique query parameter:

<img src="image.jpg?v=2" alt="My cached image" />

The awkward aspect of having to change the referred URL in every place that a changed file is used makes extending cache durations inconvenient for files that are changed often or are referred in many places.

Also, applying such policies to JavaScript or CSS becomes a minefield, because a mismatched combination of JS or CSS files could end up in the browser cache indefinitely, breaking the website for the user until URL's are changed or their browser cache is cleared. For that reason, I don't think it's prudent to do this for anything but files that never change or that have some kind of version marker in their URL.

Complete control with service workers

A static web site can take complete control over its cache behavior by using a service worker. The service worker intercepts every network request and then decides whether to serve it from a local cache or from the network. For example, here's a service worker that will cache all resources indefinitely, until its version is changed:

This recreates the far-future expiration strategy but does it client-side, inside the service worker. Because only the version at the top of the sw.js file needs to be updated when the site's contents change, this becomes practical to do without adding a build step. However, because the service worker intercepts network requests to change their behavior there is a risk that bugs could lead to a broken site, so this strategy is only for the careful and well-versed. (And no, the above service worker code hasn't been baked in production, so be careful when copying it to your own site.)

Wrapping up

Setting sane cache policies meant to optimize page load performance is one of the things typically in the domain of full-fat frameworks or application servers. But, abandoning build steps and server-side logic does not necessarily have to mean having poor caching performance. There are multiple strategies with varying amounts of cache control, and there is probably a suitable strategy for any plain vanilla site.

Last but not least, an even better way to speed up page loading is to keep the web page itself light. Using a plain vanilla approach to pages with zero dependencies baked into the page weight already puts you in pole position for good page load performance, before caching even enters the picture.

================================================ FILE: public/blog/articles/2024-12-16-caching-vanilla-sites/sw.js ================================================ let cacheName = 'cache-worker-v1'; // these are automatically cached when the site is first loaded let initialAssets = [ './', 'index.html', 'index.js', 'index.css', 'manifest.json', 'android-chrome-512x512.png', 'favicon.ico', 'apple-touch-icon.png', 'styles/reset.css', // the rest will be auto-discovered ]; // initial bundle (on first load) self.addEventListener('install', (event) => { event.waitUntil( caches.open(cacheName).then((cache) => { return cache.addAll(initialAssets); }) ); }); // clear out stale caches after service worker update self.addEventListener('activate', (event) => { event.waitUntil( caches.keys().then((cacheNames) => { return Promise.all( cacheNames.map((cacheName) => { if (cacheName !== self.cacheName) { return caches.delete(cacheName); } }) ); }) ); }); // default to fetching from cache, fallback to network self.addEventListener('fetch', (event) => { const url = new URL(event.request.url); // other origins bypass the cache if (url.origin !== location.origin) { networkOnly(event); // default to fetching from cache, and updating asynchronously } else { staleWhileRevalidate(event); } }); const networkOnly = (event) => { event.respondWith(fetch(event.request)); } // fetch events are serviced from cache if possible, but also updated behind the scenes const staleWhileRevalidate = (event) => { event.respondWith( caches.match(event.request).then(cachedResponse => { const networkUpdate = fetch(event.request).then(networkResponse => { caches.open(cacheName).then( cache => cache.put(event.request, networkResponse)); return networkResponse.clone(); }).catch(_ => /*ignore because we're probably offline*/_); return cachedResponse || networkUpdate; }) ); } ================================================ FILE: public/blog/articles/2025-01-01-new-years-resolve/example-index.js ================================================ import { registerAvatarComponent } from './components/avatar.js'; const app = () => { registerAvatarComponent(); } document.addEventListener('DOMContentLoaded', app); ================================================ FILE: public/blog/articles/2025-01-01-new-years-resolve/index.html ================================================ New year's resolve New year's fireworks

New year's resolve

Today I was looking at what I want to write about in the coming year, and while checking out custom element micro-frameworks came across import.meta.resolve in the documentation for the Ponys micro-framework. That one simple trick is part of the essential toolbox that allows skipping build-time bundling, unlocking the vanilla web development achievement in the browser.

We need this toolbox because the build-time bundler does a lot of work for us:

  • Combining JS and CSS files to avoid slow page loads.
  • Resolving paths to the JS and CSS dependencies so we can have clean import statements.
  • Optimizing the bundled code, by stripping out whitespace, and removing unused imports thanks to a tree shaking algorithm.

It is not immediately apparent how to get these benefits without using a bundler.

Combining JS and CSS files

A typical framework-based web project contains hundreds or thousands of files. Having all those files loaded separately on a page load would be intolerably slow, hence the need for a bundler to reduce the file count. Even by stripping away third party dependencies we can still end up with dozens or hundreds of files constituting a vanilla web project.

When inspecting a page load in the browser's developer tools, we would then expect to see a lot of this:

a waterfall of network requests in browser devtools over http1

The browser would download 6 files at a time and the later requests would block until those files downloaded. This limitation of HTTP/1 let to not just the solution of bundlers to reduce file count, but because the limitation of 6 parallel downloads was per domain it also led to the popularity of CDN networks which allowed cheating and downloading 12 files at once instead of 6.

However. It's 2025. What you're likely to see in this modern era is more like this:

parallel network requests in browser devtools over http2

Because almost all web servers have shifted over to HTTP/2, which no longer has this limitation of only having 6 files in flight at a time, we now see that all the files that are requested in parallel get downloaded in parallel. There's still a small caveat on lossy connections called head-of-line-blocking, fixed in HTTP/3, which is presently starting to roll out to web servers across the internet.

But the long and short of it is this: requesting a lot of files all at once just isn't that big of a problem anymore. We don't need to bundle up the files in a vanilla web project until the file counts get ridiculous.

Resolving paths

Another thing that bundlers do is resolving relative paths pointing to imported JS and CSS files. See, for example, the elegance of CSS modules for importing styles into a react component from a relative path:

However. It's 2025. And our browsers now have a modern vanilla toolbox for importing. When we bootstrap our JS with the magic incantation <script src="index.js" type="module"></script> we unlock the magic ability to import JS files using ES module import notation:

Inside of such files we also get access to import.meta.url, the URL of the current JS file, and import.meta.resolve(), a function that resolves a path relative to the current file, even a path to a CSS file:

While not quite the same as what the bundler does, it still enables accessing any file by its relative path, and that in turn allows organizing projects in whatever way we want, for example in a feature-based folder organization. All without needing a build step.

This ability to do relative imports can be super-charged by import maps, which decouple the name of what is imported from the path of the file it is imported from, again all without involving a build step.

Optimizing bundled code

Another thing bundlers can do is optimizing the bundled code, by splitting the payload into things loaded initially, and things loaded later on lazily. And also by minifying it, stripping away unnecessary whitespace and comments so it will load faster.

However. It's 2025. We can transparently enable gzip or brotli compression on the server, and as it turns out that gets almost all the benefit of minifying. While minifying is nice, gzipping is what we really want, and we can get that without a build step.

And lazy loading, that works fine using dynamic import, and with a bit due diligence we can put some of the code behind such an import statement. I wrote before how React's lazy and suspense can be ported easily to vanilla web components.

Happy new year!

Great news! It's 2025, and the browser landscape is looking better than ever. It gives us enough tools that for many web projects we can drop the bundler and do just fine. You wouldn't believe it based on what the mainstream frameworks are doing though. Maybe 2025 is the year we finally see a wide recognition of just how powerful the browser platform has gotten, and a return to old school simplicity in web development practice, away from all those complicated build steps. It's my new year's resolve to do my part in spreading the word.

================================================ FILE: public/blog/articles/2025-01-01-new-years-resolve/layout.js ================================================ class Layout extends HTMLElement { constructor() { super(); this.attachShadow({ mode: 'open' }); this.shadowRoot.innerHTML = `
`; } } export const registerLayoutComponent = () => customElements.define('x-layout', Layout); ================================================ FILE: public/blog/articles/2025-01-01-new-years-resolve/layout.tsx ================================================ import styles from './styles.module.css' export default function Layout({ children, }: { children: React.ReactNode }) { return
{children}
} ================================================ FILE: public/blog/articles/2025-04-21-attribute-property-duality/demo1.html ================================================ demo 1

Markup:

Outputs:

================================================ FILE: public/blog/articles/2025-04-21-attribute-property-duality/demo1.js ================================================ customElements.define('my-hello', class extends HTMLElement { connectedCallback() { this.textContent = `Hello, ${ this.value || 'null' }!`; } }); ================================================ FILE: public/blog/articles/2025-04-21-attribute-property-duality/demo2.html ================================================ demo 2

Markup:

Outputs:

================================================ FILE: public/blog/articles/2025-04-21-attribute-property-duality/demo2.js ================================================ customElements.define('my-hello', class extends HTMLElement { connectedCallback() { this.textContent = `Hello, ${ this.getAttribute('value') || 'null' }!`; } }); ================================================ FILE: public/blog/articles/2025-04-21-attribute-property-duality/demo3.html ================================================ demo 3

Markup:

Outputs:

================================================ FILE: public/blog/articles/2025-04-21-attribute-property-duality/demo3.js ================================================ customElements.define('my-hello', class extends HTMLElement { get value() { return this.getAttribute('value'); } set value(v) { this.setAttribute('value', String(v)); } static observedAttributes = ['value']; attributeChangedCallback() { this.textContent = `Hello, ${ this.value || 'null' }!`; } }); ================================================ FILE: public/blog/articles/2025-04-21-attribute-property-duality/demo4.html ================================================ demo 4

Markup:

Outputs:

================================================ FILE: public/blog/articles/2025-04-21-attribute-property-duality/demo4.js ================================================ customElements.define('my-hello', class extends HTMLElement { get value() { return this.getAttribute('value'); } set value(v) { this.setAttribute('value', String(v)); } get glam() { return this.hasAttribute('glam'); } set glam(v) { if (v) { this.setAttribute('glam', 'true'); } else { this.removeAttribute('glam'); } } static observedAttributes = ['value', 'glam']; attributeChangedCallback() { this.textContent = `Hello, ${ this.value || 'null' }!` + (this.glam ? '!!@#!' : ''); } }); ================================================ FILE: public/blog/articles/2025-04-21-attribute-property-duality/demo5-before.js ================================================ // html: // js: const myHello = document.querySelector('my-hello'); myHello.value = 42; // setter not called before define customElements.define('my-hello', /* ... */); console.log(myHello.getAttribute('value')); // -> "world" ================================================ FILE: public/blog/articles/2025-04-21-attribute-property-duality/demo5.html ================================================ demo 5

Markup:

Outputs:

================================================ FILE: public/blog/articles/2025-04-21-attribute-property-duality/demo5.js ================================================ customElements.define('my-hello', class extends HTMLElement { get value() { return this.getAttribute('value'); } set value(v) { this.setAttribute('value', String(v)); } get glam() { return this.hasAttribute('glam'); } set glam(v) { if (v) { this.setAttribute('glam', 'true'); } else { this.removeAttribute('glam'); } } static observedAttributes = ['value', 'glam']; attributeChangedCallback() { this.textContent = `Hello, ${ this.value || 'null' }!` + (this.glam ? '!!@#!' : ''); } connectedCallback() { this.#upgradeProperty('value'); this.#upgradeProperty('glam'); } #upgradeProperty(prop) { if (this.hasOwnProperty(prop)) { let value = this[prop]; delete this[prop]; this[prop] = value; } } }); ================================================ FILE: public/blog/articles/2025-04-21-attribute-property-duality/index.html ================================================ The attribute/property duality A cartoon angle bracket figure gladly sharing his opinions to a crowd of symbols.

The attribute/property duality

Web components, a.k.a. custom elements, are HTML elements that participate in HTML markup. As such they can have attributes:

<my-hello value="world"></my-hello>

But, they are also JavaScript objects, and as such they can have object properties.

let myHello = document.querySelector('my-hello'); myHello.value = 'foo';

And here's the tricky part about that: these are not the same thing! In fact, custom element attributes and properties by default have zero relationship between them, even when they share a name. Here's a live proof of this fact:

Now, to be fair, we can get at the attribute value just fine from JavaScript:

But what if we would also like it to have a value property? What should the relationship between attribute and property be like?

  • Does updating the attribute always update the property?
  • Does updating the property always update the attribute?
  • When updates can go either way, does the property read and update the value of the attribute, or do both attribute and property wrap around a private field on the custom element's class?
  • When updates can go either way, how to avoid loops where the property updates the attribute, which updates the property, which...
  • When is it fine to have just an attribute without a property, or a property without an attribute?

In framework-based code, we typically don't get a say in these things. Frameworks generally like to pretend that attributes and properties are the same thing, and they automatically create code to make sure this is the case. In vanilla custom elements however, not only do we get to decide these things, we must decide them.

Going native

Seasoned developers will intuitively grasp what the sensible relationship between attributes and properties should be. This is because built-in HTML elements all implement similar kinds of relationships between their attributes and their properties. To explore that in depth, I recommend reading Making Web Component properties behave closer to the platform. Without fully restating that article, here's a quick recap:

  • Properties can exist independent of an attribute, but an attribute will typically have a related property.
  • If changing the attribute updates the property, then updating the property will update the attribute.
  • Properties reflect either an internal value of an element, or the value of the corresponding attribute.
  • Assigning a value of an invalid type will coerce the value to the right type, instead of rejecting the change.
  • Change events are only dispatched for changes by user input, not from programmatic changes to attribute or property.

An easy way to get much of this behavior is to make a property wrap around an attribute:

Notice how updating the property will update the attribute in the HTML representation, and how the property's assigned value is coerced into the attribute's string type. Attributes are always strings.

Into the weeds

Up to this point, things are looking straightforward. But this is web development, things are never as straightforward as they seem. For instance, what boolean attribute value should make a corresponding boolean property become true? The surprising but standard behavior on built-in elements is that any attribute value will be interpreted as true, and only the absence of the attribute will be interpreted as false.

Time for another iteration of our element:

Which leaves us with the last bit of tricky trivia: it's possible for the custom element's class to be instantiated and attached to the element after the property is assigned. In that case the property's setter is never called, and the attribute is not updated.

This can be avoided by reassigning any previously set properties when the element is connected:

In conclusion

If that seems like a lot of work to do a very simple thing, that is because it is. The good news is: we don't have to always do this work.

When we're using web components as framework components in a codebase that we control, we don't have to follow any of these unwritten rules and can keep the web component code as simple as we like. However, when using web components as custom elements to be used in HTML markup then we do well to follow these best practices to avoid surprises, especially when making web components that may be used by others. YMMV.

In the next article, I'll be looking into custom elements that accept input, and how that adds twists to the plot.

================================================ FILE: public/blog/articles/2025-05-09-form-control/demo1/index-partial.txt ================================================

My favorite colors are and .

================================================ FILE: public/blog/articles/2025-05-09-form-control/demo1/index.html ================================================ demo 1

My favorite colors are and .


================================================ FILE: public/blog/articles/2025-05-09-form-control/demo1/input-inline.js ================================================ customElements.define('input-inline', class extends HTMLElement { get value() { return this.getAttribute('value') ?? ''; } set value(value) { this.setAttribute('value', String(value)); } get name() { return this.getAttribute('name') ?? ''; } set name(v) { this.setAttribute('name', String(v)); } connectedCallback() { this.#update(); } static observedAttributes = ['value']; attributeChangedCallback() { this.#update(); } #update() { this.style.display = 'inline'; if (this.textContent !== this.value) { this.textContent = this.value; } this.contentEditable = true; } }); ================================================ FILE: public/blog/articles/2025-05-09-form-control/demo2/index.html ================================================ demo 2

My favorite colors are and .


================================================ FILE: public/blog/articles/2025-05-09-form-control/demo2/input-inline-partial.js ================================================ customElements.define('input-inline', class extends HTMLElement { #internals; /* ... */ constructor() { super(); this.#internals = this.attachInternals(); this.#internals.role = 'textbox'; } /* ... */ #update() { /* ... */ this.#internals.setFormValue(this.value); } static formAssociated = true; }); ================================================ FILE: public/blog/articles/2025-05-09-form-control/demo2/input-inline.js ================================================ customElements.define('input-inline', class extends HTMLElement { #internals; get value() { return this.getAttribute('value') ?? ''; } set value(value) { this.setAttribute('value', String(value)); } get name() { return this.getAttribute('name') ?? ''; } set name(v) { this.setAttribute('name', String(v)); } constructor() { super(); this.#internals = this.attachInternals(); this.#internals.role = 'textbox'; } connectedCallback() { this.#update(); } static observedAttributes = ['value']; attributeChangedCallback() { this.#update(); } #update() { this.style.display = 'inline'; if (this.textContent !== this.value) { this.textContent = this.value; } this.contentEditable = true; this.#internals.setFormValue(this.value); } static formAssociated = true; }); ================================================ FILE: public/blog/articles/2025-05-09-form-control/demo3/index.html ================================================ demo 3

My favorite colors are and .


================================================ FILE: public/blog/articles/2025-05-09-form-control/demo3/input-inline-partial.js ================================================ customElements.define('input-inline', class extends HTMLElement { #shouldFireChange = false; /* ... */ constructor() { /* ... */ this.addEventListener('input', this); this.addEventListener('keydown', this); this.addEventListener('paste', this); this.addEventListener('focusout', this); } handleEvent(e) { switch (e.type) { // respond to user input (typing, drag-and-drop, paste) case 'input': this.value = cleanTextContent(this.textContent); this.#shouldFireChange = true; break; // enter key should submit form instead of adding a new line case 'keydown': if (e.key === 'Enter') { e.preventDefault(); this.#internals.form?.requestSubmit(); } break; // prevent pasting rich text (firefox), or newlines (all browsers) case 'paste': e.preventDefault(); const text = e.clipboardData.getData('text/plain') // replace newlines and tabs with spaces .replace(/[\n\r\t]+/g, ' ') // limit length of pasted text to something reasonable .substring(0, 1000); // shadowRoot.getSelection is non-standard, fallback to document in firefox // https://stackoverflow.com/a/70523247 let selection = this.getRootNode()?.getSelection?.() || document.getSelection(); let range = selection.getRangeAt(0); range.deleteContents(); range.insertNode(document.createTextNode(text)); // manually trigger input event to restore default behavior this.dispatchEvent(new Event('input', { bubbles: true, composed: true })); break; // fire change event on blur case 'focusout': if (this.#shouldFireChange) { this.#shouldFireChange = false; this.dispatchEvent(new Event('change', { bubbles: true, composed: true })); } break; } } /* ... */ }); function cleanTextContent(text) { return (text ?? '') // replace newlines and tabs with spaces .replace(/[\n\r\t]+/g, ' '); } ================================================ FILE: public/blog/articles/2025-05-09-form-control/demo3/input-inline.js ================================================ customElements.define('input-inline', class extends HTMLElement { #shouldFireChange = false; #internals; get value() { return this.getAttribute('value') ?? ''; } set value(value) { this.setAttribute('value', String(value)); } get name() { return this.getAttribute('name') ?? ''; } set name(v) { this.setAttribute('name', String(v)); } constructor() { super(); this.#internals = this.attachInternals(); this.#internals.role = 'textbox'; // add event listeners this.addEventListener('input', this); this.addEventListener('keydown', this); this.addEventListener('paste', this); this.addEventListener('focusout', this); } handleEvent(e) { switch (e.type) { // respond to user input (typing, drag-and-drop, paste) case 'input': this.value = cleanTextContent(this.textContent); this.#shouldFireChange = true; break; // enter key should submit form instead of adding a new line case 'keydown': if (e.key === 'Enter') { e.preventDefault(); this.#internals.form?.requestSubmit(); } break; // prevent pasting rich text (firefox), or newlines (all browsers) case 'paste': e.preventDefault(); const text = e.clipboardData.getData('text/plain') // replace newlines and tabs with spaces .replace(/[\n\r\t]+/g, ' ') // limit length of pasted text to something reasonable .substring(0, 1000); // shadowRoot.getSelection is non-standard, fallback to document in firefox // https://stackoverflow.com/a/70523247 let selection = this.getRootNode()?.getSelection?.() || document.getSelection(); let range = selection.getRangeAt(0); range.deleteContents(); range.insertNode(document.createTextNode(text)); // manually trigger input event to restore default behavior this.dispatchEvent(new Event('input', { bubbles: true, composed: true })); break; // fire change event on blur case 'focusout': if (this.#shouldFireChange) { this.#shouldFireChange = false; this.dispatchEvent(new Event('change', { bubbles: true, composed: true })); } break; } } connectedCallback() { this.#update(); } static observedAttributes = ['value']; attributeChangedCallback() { this.#update(); } #update() { this.style.display = 'inline'; if (cleanTextContent(this.textContent) !== this.value) { this.textContent = this.value; } this.contentEditable = true; this.#internals.setFormValue(this.value); } static formAssociated = true; }); function cleanTextContent(text) { return (text ?? '') // replace newlines and tabs with spaces .replace(/[\n\r\t]+/g, ' ') // remove zero width spaces .replace(/\u200B/g, ''); } ================================================ FILE: public/blog/articles/2025-05-09-form-control/demo4/index.html ================================================ demo 4

My favorite colors are and .

Disabled:

Readonly:


================================================ FILE: public/blog/articles/2025-05-09-form-control/demo4/input-inline-partial.js ================================================ customElements.define('input-inline', class extends HTMLElement { /* ... */ #formDisabled = false; #value; set value(v) { if (this.#value !== String(v)) { this.#value = String(v); this.#update(); } } get value() { return this.#value ?? this.defaultValue; } get defaultValue() { return this.getAttribute('value') ?? ''; } set defaultValue(value) { this.setAttribute('value', String(value)); } set disabled(v) { if (v) { this.setAttribute('disabled', 'true'); } else { this.removeAttribute('disabled'); } } get disabled() { return this.hasAttribute('disabled'); } set readOnly(v) { if (v) { this.setAttribute('readonly', 'true'); } else { this.removeAttribute('readonly'); } } get readOnly() { return this.hasAttribute('readonly'); } /* ... */ static observedAttributes = ['value', 'disabled', 'readonly']; attributeChangedCallback() { this.#update(); } #update() { this.style.display = 'inline'; this.textContent = this.value; this.#internals.setFormValue(this.value); const isDisabled = this.#formDisabled || this.disabled; this.#internals.ariaDisabled = isDisabled; this.#internals.ariaReadOnly = this.readOnly; this.contentEditable = !this.readOnly && !isDisabled && 'plaintext-only'; this.tabIndex = isDisabled ? -1 : 0; } static formAssociated = true; formResetCallback() { this.#value = undefined; this.#update(); } formDisabledCallback(disabled) { this.#formDisabled = disabled; this.#update(); } formStateRestoreCallback(state) { this.#value = state ?? undefined; this.#update(); } }); /* ... */ ================================================ FILE: public/blog/articles/2025-05-09-form-control/demo4/input-inline.js ================================================ customElements.define('input-inline', class extends HTMLElement { #shouldFireChange = false; #internals; #formDisabled = false; #value; set value(v) { if (this.#value !== String(v)) { this.#value = String(v); this.#update(); } } get value() { return this.#value ?? this.defaultValue; } get defaultValue() { return this.getAttribute('value') ?? ''; } set defaultValue(value) { this.setAttribute('value', String(value)); } set disabled(v) { if (v) { this.setAttribute('disabled', 'true'); } else { this.removeAttribute('disabled'); } } get disabled() { return this.hasAttribute('disabled'); } set readOnly(v) { if (v) { this.setAttribute('readonly', 'true'); } else { this.removeAttribute('readonly'); } } get readOnly() { return this.hasAttribute('readonly'); } get name() { return this.getAttribute('name') ?? ''; } set name(v) { this.setAttribute('name', String(v)); } constructor() { super(); this.#internals = this.attachInternals(); this.#internals.role = 'textbox'; // add event listeners this.addEventListener('input', this); this.addEventListener('keydown', this); this.addEventListener('paste', this); this.addEventListener('focusout', this); } handleEvent(e) { switch (e.type) { // respond to user input (typing, drag-and-drop, paste) case 'input': this.value = cleanTextContent(this.textContent); this.#shouldFireChange = true; break; // enter key should submit form instead of adding a new line case 'keydown': if (e.key === 'Enter') { e.preventDefault(); this.#internals.form?.requestSubmit(); } break; // prevent pasting rich text (firefox), or newlines (all browsers) case 'paste': e.preventDefault(); const text = e.clipboardData.getData('text/plain') // replace newlines and tabs with spaces .replace(/[\n\r\t]+/g, ' ') // limit length of pasted text to something reasonable .substring(0, 1000); // shadowRoot.getSelection is non-standard, fallback to document in firefox // https://stackoverflow.com/a/70523247 let selection = this.getRootNode()?.getSelection?.() || document.getSelection(); let range = selection.getRangeAt(0); range.deleteContents(); range.insertNode(document.createTextNode(text)); // manually trigger input event to restore default behavior this.dispatchEvent(new Event('input', { bubbles: true, composed: true })); break; // fire change event on blur case 'focusout': if (this.#shouldFireChange) { this.#shouldFireChange = false; this.dispatchEvent(new Event('change', { bubbles: true, composed: true })); } break; } } connectedCallback() { this.#update(); } static observedAttributes = ['value', 'disabled', 'readonly']; attributeChangedCallback() { this.#update(); } #update() { this.style.display = 'inline'; if (cleanTextContent(this.textContent) !== this.value) { this.textContent = this.value; } this.#internals.setFormValue(this.value); const isDisabled = this.#formDisabled || this.disabled; this.#internals.ariaDisabled = isDisabled; this.#internals.ariaReadOnly = this.readOnly; this.contentEditable = !this.readOnly && !isDisabled && 'plaintext-only'; this.tabIndex = isDisabled ? -1 : 0; } static formAssociated = true; formResetCallback() { this.#value = undefined; this.#update(); } formDisabledCallback(disabled) { this.#formDisabled = disabled; this.#update(); } formStateRestoreCallback(state) { this.#value = state ?? undefined; this.#update(); } }); function cleanTextContent(text) { return (text ?? '') // replace newlines and tabs with spaces .replace(/[\n\r\t]+/g, ' ') // remove zero width spaces .replace(/\u200B/g, ''); } ================================================ FILE: public/blog/articles/2025-05-09-form-control/demo5/index.html ================================================ demo 5

My favorite colors are and .

Disabled:

Readonly:


================================================ FILE: public/blog/articles/2025-05-09-form-control/demo5/input-inline.css ================================================ /* default styling has lowest priority */ @layer { :root { --input-inline-border-color: light-dark(rgb(118, 118, 118), rgb(161, 161, 161)); --input-inline-border-color-hover: light-dark(rgb(78, 78, 78), rgb(200, 200, 200)); --input-inline-border-color-disabled: rgba(150, 150, 150, 0.5); --input-inline-text-color: light-dark(fieldtext, rgb(240, 240, 240)); --input-inline-text-color-disabled: light-dark(rgb(84, 84, 84), rgb(170, 170, 170)); --input-inline-bg-color: inherit; --input-inline-bg-color-disabled: inherit; --input-inline-min-width: 4ch; } input-inline { display: inline; background-color: var(--input-inline-bg-color); color: var(--input-inline-text-color); border: 1px dotted var(--input-inline-border-color); padding: 2px 3px; margin-bottom: -2px; border-radius: 3px; /* minimum width */ padding-right: max(3px, calc(var(--input-inline-min-width) - var(--current-length))); &:hover { border-color: var(--input-inline-border-color-hover); } &:disabled { border-color: var(--input-inline-border-color-disabled); background-color: var(--input-inline-bg-color-disabled); color: var(--input-inline-text-color-disabled); -webkit-user-select: none; user-select: none; } &:focus-visible { border-color: transparent; outline-offset: 0; outline: 2px solid royalblue; /* firefox */ outline-color: -webkit-focus-ring-color; /* the rest */ } } @media screen and (-webkit-min-device-pixel-ratio:0) { input-inline:empty::before { /* fixes issue where empty input-inline shifts left in chromium browsers */ content: " "; } } } ================================================ FILE: public/blog/articles/2025-05-09-form-control/demo5/input-inline.js ================================================ customElements.define('input-inline', class extends HTMLElement { #shouldFireChange = false; #internals; #formDisabled = false; #value; set value(v) { if (this.#value !== String(v)) { this.#value = String(v); this.#update(); } } get value() { return this.#value ?? this.defaultValue; } get defaultValue() { return this.getAttribute('value') ?? ''; } set defaultValue(value) { this.setAttribute('value', String(value)); } set disabled(v) { if (v) { this.setAttribute('disabled', 'true'); } else { this.removeAttribute('disabled'); } } get disabled() { return this.hasAttribute('disabled'); } set readOnly(v) { if (v) { this.setAttribute('readonly', 'true'); } else { this.removeAttribute('readonly'); } } get readOnly() { return this.hasAttribute('readonly'); } get name() { return this.getAttribute('name') ?? ''; } set name(v) { this.setAttribute('name', String(v)); } constructor() { super(); this.#internals = this.attachInternals(); this.#internals.role = 'textbox'; // add event listeners this.addEventListener('input', this); this.addEventListener('keydown', this); this.addEventListener('paste', this); this.addEventListener('focusout', this); } handleEvent(e) { switch (e.type) { // respond to user input (typing, drag-and-drop, paste) case 'input': this.value = cleanTextContent(this.textContent); this.#shouldFireChange = true; break; // enter key should submit form instead of adding a new line case 'keydown': if (e.key === 'Enter') { e.preventDefault(); this.#internals.form?.requestSubmit(); } break; // prevent pasting rich text (firefox), or newlines (all browsers) case 'paste': e.preventDefault(); const text = e.clipboardData.getData('text/plain') // replace newlines and tabs with spaces .replace(/[\n\r\t]+/g, ' ') // limit length of pasted text to something reasonable .substring(0, 1000); // shadowRoot.getSelection is non-standard, fallback to document in firefox // https://stackoverflow.com/a/70523247 let selection = this.getRootNode()?.getSelection?.() || document.getSelection(); let range = selection.getRangeAt(0); range.deleteContents(); range.insertNode(document.createTextNode(text)); // manually trigger input event to restore default behavior this.dispatchEvent(new Event('input', { bubbles: true, composed: true })); break; // fire change event on blur case 'focusout': if (this.#shouldFireChange) { this.#shouldFireChange = false; this.dispatchEvent(new Event('change', { bubbles: true, composed: true })); } break; } } connectedCallback() { this.#update(); } static observedAttributes = ['value', 'disabled', 'readonly']; attributeChangedCallback() { this.#update(); } #update() { this.style.display = 'inline'; if (cleanTextContent(this.textContent) !== this.value) { this.textContent = this.value; } this.#internals.setFormValue(this.value); const isDisabled = this.#formDisabled || this.disabled; this.#internals.ariaDisabled = isDisabled; this.#internals.ariaReadOnly = this.readOnly; this.contentEditable = !this.readOnly && !isDisabled && 'plaintext-only'; this.tabIndex = isDisabled ? -1 : 0; const length = cleanTextContent(this.textContent).length || 0; this.style.setProperty('--current-length', `${length}ch`); } static formAssociated = true; formResetCallback() { this.#value = undefined; this.#update(); } formDisabledCallback(disabled) { this.#formDisabled = disabled; this.#update(); } formStateRestoreCallback(state) { this.#value = state ?? undefined; this.#update(); } }); function cleanTextContent(text) { return (text ?? '') // replace newlines and tabs with spaces .replace(/[\n\r\t]+/g, ' ') // remove zero width spaces .replace(/\u200B/g, ''); } ================================================ FILE: public/blog/articles/2025-05-09-form-control/demo6/index-partial.txt ================================================

My favorite color is .

================================================ FILE: public/blog/articles/2025-05-09-form-control/demo6/index.html ================================================ demo 6

My favorite color is .

================================================ FILE: public/blog/articles/2025-05-09-form-control/demo6/input-inline-partial.js ================================================ let VALUE_MISSING_MESSAGE = 'Please fill out this field.'; (() => { const input = document.createElement('input'); input.required = true; input.reportValidity(); VALUE_MISSING_MESSAGE = input.validationMessage; })(); const isSafari = /^((?!chrome|android).)*safari/i.test(navigator.userAgent); customElements.define('input-inline', class extends HTMLElement { /* ... */ #customValidityMessage = ''; /* ... */ set required(v) { if (v) { this.setAttribute('required', 'true'); } else { this.removeAttribute('required'); } } get required() { return this.hasAttribute('required'); } /* ... */ static observedAttributes = ['value', 'disabled', 'readonly', 'required']; attributeChangedCallback() { this.#update(); } #update() { /* ... */ this.#internals.ariaRequired = this.required; this.#updateValidity(); } /* ... */ #updateValidity() { const state = {}; let message = ''; // custom validity message overrides all else if (this.#customValidityMessage) { state.customError = true; message = this.#customValidityMessage; } else { if (this.required && !this.value) { state.valueMissing = true; message = VALUE_MISSING_MESSAGE; } // add other checks here if needed (e.g., pattern, minLength) } // safari needs a focusable validation anchor to show the validation message on form submit // and it must be a descendant of the input let anchor = undefined; if (isSafari) { anchor = this.querySelector('span[aria-hidden]'); if (!anchor) { anchor = document.createElement('span'); anchor.ariaHidden = true; anchor.tabIndex = 0; this.append(anchor); } } this.#internals.setValidity(state, message, anchor); } checkValidity() { this.#updateValidity(); return this.#internals.checkValidity(); } reportValidity() { this.#updateValidity(); return this.#internals.reportValidity(); } setCustomValidity(message) { this.#customValidityMessage = message ?? ''; this.#updateValidity(); } get validity() { return this.#internals.validity; } get validationMessage() { return this.#internals.validationMessage; } get willValidate() { return this.#internals.willValidate; } }); /* ... */ ================================================ FILE: public/blog/articles/2025-05-09-form-control/demo6/input-inline.css ================================================ /* default styling has lowest priority */ @layer { :root { --input-inline-border-color: light-dark(rgb(118, 118, 118), rgb(161, 161, 161)); --input-inline-border-color-hover: light-dark(rgb(78, 78, 78), rgb(200, 200, 200)); --input-inline-border-color-disabled: rgba(150, 150, 150, 0.5); --input-inline-text-color: light-dark(fieldtext, rgb(240, 240, 240)); --input-inline-text-color-disabled: light-dark(rgb(84, 84, 84), rgb(170, 170, 170)); --input-inline-bg-color: inherit; --input-inline-bg-color-disabled: inherit; --input-inline-min-width: 4ch; } input-inline { display: inline; background-color: var(--input-inline-bg-color); color: var(--input-inline-text-color); border: 1px dotted var(--input-inline-border-color); padding: 2px 3px; margin-bottom: -2px; border-radius: 3px; /* minimum width */ padding-right: max(3px, calc(var(--input-inline-min-width) - var(--current-length))); &:hover { border-color: var(--input-inline-border-color-hover); } &:disabled { border-color: var(--input-inline-border-color-disabled); background-color: var(--input-inline-bg-color-disabled); color: var(--input-inline-text-color-disabled); -webkit-user-select: none; user-select: none; } &:focus-visible { border-color: transparent; outline-offset: 0; outline: 2px solid royalblue; /* firefox */ outline-color: -webkit-focus-ring-color; /* the rest */ } } @media screen and (-webkit-min-device-pixel-ratio:0) { input-inline:empty::before { /* fixes issue where empty input-inline shifts left in chromium browsers */ content: " "; } } } ================================================ FILE: public/blog/articles/2025-05-09-form-control/demo6/input-inline.js ================================================ let VALUE_MISSING_MESSAGE = 'Please fill out this field.'; (() => { const input = document.createElement('input'); input.required = true; input.reportValidity(); VALUE_MISSING_MESSAGE = input.validationMessage; })(); const isSafari = /^((?!chrome|android).)*safari/i.test(navigator.userAgent); customElements.define('input-inline', class extends HTMLElement { #shouldFireChange = false; #internals; #formDisabled = false; #value; #customValidityMessage = ''; set value(v) { if (this.#value !== String(v)) { this.#value = String(v); this.#update(); } } get value() { return this.#value ?? this.defaultValue; } get defaultValue() { return this.getAttribute('value') ?? ''; } set defaultValue(value) { this.setAttribute('value', String(value)); } set disabled(v) { if (v) { this.setAttribute('disabled', 'true'); } else { this.removeAttribute('disabled'); } } get disabled() { return this.hasAttribute('disabled'); } set readOnly(v) { if (v) { this.setAttribute('readonly', 'true'); } else { this.removeAttribute('readonly'); } } get readOnly() { return this.hasAttribute('readonly'); } get name() { return this.getAttribute('name') ?? ''; } set name(v) { this.setAttribute('name', String(v)); } set required(v) { if (v) { this.setAttribute('required', 'true'); } else { this.removeAttribute('required'); } } get required() { return this.hasAttribute('required'); } constructor() { super(); this.#internals = this.attachInternals(); this.#internals.role = 'textbox'; // add event listeners this.addEventListener('input', this); this.addEventListener('keydown', this); this.addEventListener('paste', this); this.addEventListener('focusout', this); } handleEvent(e) { switch (e.type) { // respond to user input (typing, drag-and-drop, paste) case 'input': this.value = cleanTextContent(this.textContent); this.#shouldFireChange = true; break; // enter key should submit form instead of adding a new line case 'keydown': if (e.key === 'Enter') { e.preventDefault(); this.#internals.form?.requestSubmit(); } break; // prevent pasting rich text (firefox), or newlines (all browsers) case 'paste': e.preventDefault(); const text = e.clipboardData.getData('text/plain') // replace newlines and tabs with spaces .replace(/[\n\r\t]+/g, ' ') // limit length of pasted text to something reasonable .substring(0, 1000); // shadowRoot.getSelection is non-standard, fallback to document in firefox // https://stackoverflow.com/a/70523247 let selection = this.getRootNode()?.getSelection?.() || document.getSelection(); let range = selection.getRangeAt(0); range.deleteContents(); range.insertNode(document.createTextNode(text)); // manually trigger input event to restore default behavior this.dispatchEvent(new Event('input', { bubbles: true, composed: true })); break; // fire change event on blur case 'focusout': if (this.#shouldFireChange) { this.#shouldFireChange = false; this.dispatchEvent(new Event('change', { bubbles: true, composed: true })); } break; } } connectedCallback() { this.#update(); } static observedAttributes = ['value', 'disabled', 'readonly', 'required']; attributeChangedCallback() { this.#update(); } #update() { this.style.display = 'inline'; if (cleanTextContent(this.textContent) !== this.value) { this.textContent = this.value; } this.#internals.setFormValue(this.value); const isDisabled = this.#formDisabled || this.disabled; this.#internals.ariaDisabled = isDisabled; this.#internals.ariaReadOnly = this.readOnly; this.contentEditable = !this.readOnly && !isDisabled && 'plaintext-only'; this.tabIndex = isDisabled ? -1 : 0; const length = cleanTextContent(this.textContent).length || 0; this.style.setProperty('--current-length', `${length}ch`); this.#internals.ariaRequired = this.required; this.#updateValidity(); } static formAssociated = true; formResetCallback() { this.#value = undefined; this.#update(); } formDisabledCallback(disabled) { this.#formDisabled = disabled; this.#update(); } formStateRestoreCallback(state) { this.#value = state ?? undefined; this.#update(); } #updateValidity() { const state = {}; let message = ''; // custom validity message overrides all else if (this.#customValidityMessage) { state.customError = true; message = this.#customValidityMessage; } else { if (this.required && !this.value) { state.valueMissing = true; message = VALUE_MISSING_MESSAGE; } // add other checks here if needed (e.g., pattern, minLength) } // safari needs a focusable validation anchor to show the validation message on form submit // and it must be a descendant of the input let anchor = undefined; if (isSafari) { anchor = this.querySelector('span[aria-hidden]'); if (!anchor) { anchor = document.createElement('span'); anchor.ariaHidden = true; anchor.tabIndex = 0; this.append(anchor); } } this.#internals.setValidity(state, message, anchor); } checkValidity() { this.#updateValidity(); return this.#internals.checkValidity(); } reportValidity() { this.#updateValidity(); return this.#internals.reportValidity(); } setCustomValidity(message) { this.#customValidityMessage = message ?? ''; this.#updateValidity(); } get validity() { return this.#internals.validity; } get validationMessage() { return this.#internals.validationMessage; } get willValidate() { return this.#internals.willValidate; } }); function cleanTextContent(text) { return (text ?? '') // replace newlines and tabs with spaces .replace(/[\n\r\t]+/g, ' ') // remove zero width spaces .replace(/\u200B/g, ''); } ================================================ FILE: public/blog/articles/2025-05-09-form-control/index.html ================================================ Making a new form control A MAD magazine cover of people working hard at a gym.

Making a new form control

There are some things that a web developer knows they shouldn't attempt. Making clever use of contenteditable. Building custom form controls. Making complicated custom elements without a framework. But do we really know what we think we know? Why not try to do all three, just for fun? Could it really be that bad?

Narrator: it would indeed be that bad.

This article is building on the previous one on proper attribute/property relations in custom elements. Read that first if you haven't yet. In this piece we're taking it a step further to build a custom element that handles input. The mission is simple: implement a basic version of <input type="text" /> but with display: inline layout.

A simple element

Let's start by just throwing something against the wall and playing around with it.

And here's how we use it:

This is simple, clean, and horribly broken. For one, the form cannot see these controls at all and submits the empty object.

Form-associated elements

To fix that, we have to make a form-associated custom element. This is done through the magic of the formAssociated property and ElementInternals.

ElementInternals offers a control surface for setting the behavior of our custom element as part of a form. The this.#internals.role = 'textbox' assignment sets a default role that can be overridden by the element's user through the role attribute or property, just like for built-in form controls. By calling this.#internals.setFormValue every time the control's value changes the form will know what value to submit. But ... while the form does submit the values for our controls now, it does not see the changes we make. That's because we aren't responding to input yet.

Looking for input

Ostensibly responding to input is just adding a few event listeners in connectedCallback and removing them again in disconnectedCallback. But doing it that way quickly gets verbose. An easy alternative is to instead rely on some of the built-in event logic magic, namely that events bubble and that objects can be listeners too.

I prefer this pattern because it simplifies the code a lot compared to having separate handler functions. Attaching event listeners in the constructor instead of attaching and detaching them in the lifecycle callbacks is another simplification. It may seem like blasphemy to never clean up the event listeners, but DOM event listeners are weakly bound and garbage collection of the element can still occur with them attached. So this is fine.

In the event handler logic there's some verbosity to deal with the fallout of working with contenteditable. As this code is not the focus of this article, I won't dally on it except to remark that contenteditable is still just as annoying as you thought it was.

With these changes our element will now also emit input and change events just like a built-in HTML form control. But, you may have noticed another issue has cropped up. The standard form reset button does not actually reset the form.

Read the instructions

You see, when we said static formAssociated = true we entered into a contract to faithfully implement the expected behavior of a form control. That means we have a bunch of extra work to do.

There's a LOT going on there. It's too much to explain, so let me sum up.

  • The value attribute now corresponds to a defaultValue property, which is the value shown until changed and also the value that the form will reset the field to.
  • The value property contains only the modified value and does not correspond to an attribute.
  • The control can be marked disabled or read-only through attribute or property.
  • The form callbacks are implemented, so the control can be reset to its default value, will restore its last value after back-navigation, and will disable itself when it is in a disabled fieldset.

With some style

Up to this point we've been using some stand-in styling. However, it would be nice to have some default styling that can be bundled with our custom form control. Something like this:

The styles are isolated by scoping them to the name of our custom element, and the use of @layer puts them at the lowest priority, so that any user style will override the default style, just like for the built-in form controls. The use of variables offers an additional way to quickly restyle the control.

In the styling we also see the importance of properly thinking out disabled and focused state behavior. The upside and downside of building a custom form control is that we get to implement all the behavior that's normally built-in to the browser.

We're now past the 150 lines mark, just to get to the low bar of implementing the browser's mandatory form control behavior. So, are we done? Well, not quite. There's still one thing that form controls do, and although it's optional it's also kind of required.

Validation

Built-in form controls come with a validity API. To get an idea of what it means to implement it in a custom form control, let's add one validation attribute: required. It doesn't seem like it should take a lot of work, right?

The code for the example is exactly like it would be for built-in controls:

The ElementInternals interface is doing a lot of the work here, but we still have to proxy its methods and properties. You can tell however that by this point we're deep in the weeds of custom elements, because of the rough edges.

  • The example is using the input-inline:invalid style instead of :user-invalid because :user-invalid is not supported on custom elements yet.
  • An ugly hack is needed to get the properly localized message for a required field that matches that of built-in controls.
  • Safari flat-out won't show validation messages on non-shadowed form-associated custom elements if we don't give it an anchor to set them to, requiring another ugly hack.

In conclusion

We've established by now that it is indeed feasible to build a custom form control and have it take part in regular HTML forms, but also that it is a path surrounded by peril as well as laborious to travel. Whether it is worth doing is in the eye of the beholder.

Along that path we also learned some lessons on how to handle input in custom elements, and have proven yet again that contenteditable, while less painful than it once was, is an attribute that can only be used in anger.

Regardless, the full source code of the input-inline form control is on GitHub.

================================================ FILE: public/blog/articles/2025-06-12-view-transitions/example1/index.html ================================================ Example 1
================================================ FILE: public/blog/articles/2025-06-12-view-transitions/example1/index.js ================================================ function transition() { const square1 = document.getElementById('square1'); if (document.startViewTransition) { document.startViewTransition(() => { square1.classList.toggle('toggled'); }); } else { square1.classList.toggle('toggled'); } } ================================================ FILE: public/blog/articles/2025-06-12-view-transitions/example1/transitions.css ================================================ #square1 { background-color: orange; } #square1.toggled { background-color: blue; } ================================================ FILE: public/blog/articles/2025-06-12-view-transitions/example2/index.html ================================================ Example 2

================================================ FILE: public/blog/articles/2025-06-12-view-transitions/example2/index.js ================================================ function transition() { const square1 = document.getElementById('square1'); const square2 = document.getElementById('square2'); if (document.startViewTransition) { document.startViewTransition(() => { square1.classList.toggle('toggled'); square2.classList.toggle('toggled'); }); } else { square1.classList.toggle('toggled'); square2.classList.toggle('toggled'); } } ================================================ FILE: public/blog/articles/2025-06-12-view-transitions/example2/transitions.css ================================================ #square2 { background-color: green; view-transition-name: slide; display: none; } #square2.toggled { display: inline-block; } ::view-transition-new(slide):only-child { animation: 400ms ease-in both slide-in; } @keyframes slide-in { from { transform: translateY(-200px); } to { transform: translateY(0); } } ================================================ FILE: public/blog/articles/2025-06-12-view-transitions/example3/index.html ================================================ Example 3
================================================ FILE: public/blog/articles/2025-06-12-view-transitions/example3/index.js ================================================ import { startTransition } from './view-transition.js'; export function transition() { startTransition(() => { document.getElementById('square1').classList.toggle('toggled'); document.getElementById('square2').classList.toggle('toggled'); }); } ================================================ FILE: public/blog/articles/2025-06-12-view-transitions/example3/transitions.css ================================================ #square2 { background-color: green; view-transition-name: slide; display: none; } #square2.toggled { display: inline-block; } ::view-transition-new(slide):only-child { animation: 400ms ease-in both slide-in; } @keyframes slide-in { from { transform: translateY(-200px); } to { transform: translateY(0); } } ================================================ FILE: public/blog/articles/2025-06-12-view-transitions/example3/view-transition.js ================================================ export const startTransition = (updateCallback) => { if (document.startViewTransition) { document.startViewTransition(updateCallback); } else { const done = Promise.try(updateCallback); return { updateCallbackDone: done, ready: done, finished: done, skipTransition: () => {} }; } } ================================================ FILE: public/blog/articles/2025-06-12-view-transitions/example4/index.html ================================================ Example 4
================================================ FILE: public/blog/articles/2025-06-12-view-transitions/example4/index.js ================================================ import { startTransition } from './view-transition.js'; let currentRoute = ''; export function navigate() { currentRoute = currentRoute === 'route2' ? 'route1' : 'route2'; updateRoute1(); updateRoute2(); } function updateRoute1() { startTransition(() => { if (currentRoute === 'route1') { document.getElementById('route1').classList.add('active'); } else { document.getElementById('route1').classList.remove('active'); } }); } function updateRoute2() { startTransition(() => { const route2 = document.getElementById('route2'); if (currentRoute === 'route2') { route2.classList.add('active', 'loading'); route2.textContent = '...'; load().then((data) => startTransition(() => { route2.textContent = data; route2.classList.remove('loading'); })); } else { document.getElementById('route2').classList.remove('active'); } }); } function load() { return new Promise((resolve) => { setTimeout(() => { resolve('Hi!'); }, 250); }); } ================================================ FILE: public/blog/articles/2025-06-12-view-transitions/example4/transitions.css ================================================ .route { display: none; } .route.active { display: inline-block; } .route#route2 { view-transition-name: slide; } ::view-transition-new(slide):only-child { animation: 400ms ease-in both slide-in; } @keyframes slide-in { from { transform: translateY(-200px); } to { transform: translateY(0); } } ================================================ FILE: public/blog/articles/2025-06-12-view-transitions/example4/view-transition.js ================================================ export const startTransition = (updateCallback) => { if (document.startViewTransition) { document.startViewTransition(updateCallback); } else { const done = Promise.try(updateCallback); return { updateCallbackDone: done, ready: done, finished: done, skipTransition: () => {} }; } } ================================================ FILE: public/blog/articles/2025-06-12-view-transitions/example5/index.html ================================================ Example 5
================================================ FILE: public/blog/articles/2025-06-12-view-transitions/example5/index.js ================================================ import { startTransition } from './view-transition.js'; let currentRoute = ''; export function navigate() { currentRoute = currentRoute === 'route2' ? 'route1' : 'route2'; updateRoute1(); updateRoute2(); } function updateRoute1() { startTransition(() => { if (currentRoute === 'route1') { document.getElementById('route1').classList.add('active'); } else { document.getElementById('route1').classList.remove('active'); } }); } function updateRoute2() { startTransition(() => { const route2 = document.getElementById('route2'); if (currentRoute === 'route2') { route2.classList.add('active', 'loading'); route2.textContent = '...'; load().then((data) => startTransition(() => { route2.textContent = data; route2.classList.remove('loading'); })); } else { document.getElementById('route2').classList.remove('active'); } }); } function load() { return new Promise((resolve) => { setTimeout(() => { resolve('Hi!'); }, 250); }); } ================================================ FILE: public/blog/articles/2025-06-12-view-transitions/example5/transitions.css ================================================ .route { display: none; } .route.active { display: inline-block; } .route#route2 { view-transition-name: slide; } ::view-transition-new(slide):only-child { animation: 400ms ease-in both slide-in; } @keyframes slide-in { from { transform: translateY(-200px); } to { transform: translateY(0); } } ================================================ FILE: public/blog/articles/2025-06-12-view-transitions/example5/view-transition-part.js ================================================ // the currently animating view transition let currentTransition = null; // the next transition to run (after currentTransition completes) let nextTransition = null; /** start a view transition or queue it for later if one is already animating */ export const startTransition = (updateCallback) => { if (!updateCallback) updateCallback = () => {}; // a transition is active if (currentTransition && !currentTransition.isFinished) { // it is running callbacks, but not yet animating if (!currentTransition.isReady) { currentTransition.addCallback(updateCallback); return currentTransition; // it is already animating, queue callback in the next transition } else { if (!nextTransition) { nextTransition = new QueueingViewTransition(); } return nextTransition.addCallback(updateCallback); } // if no transition is active, start animating the new transition } else { currentTransition = new QueueingViewTransition(); currentTransition.addCallback(updateCallback); currentTransition.run(); // after it's done, execute any queued transition const doNext = () => { if (nextTransition) { currentTransition = nextTransition; nextTransition = null; currentTransition.run(); currentTransition.finished.finally(doNext); } else { currentTransition = null; } } currentTransition.finished.finally(doNext); return currentTransition; } } // ... ================================================ FILE: public/blog/articles/2025-06-12-view-transitions/example5/view-transition.js ================================================ // the currently animating view transition let currentTransition = null; // the next transition to run (after currentTransition completes) let nextTransition = null; /** start a view transition or queue it for later if one is already animating */ export const startTransition = (updateCallback) => { if (!updateCallback) updateCallback = () => {}; // a transition is active if (currentTransition && !currentTransition.isFinished) { // it is running callbacks, but not yet animating if (!currentTransition.isReady) { currentTransition.addCallback(updateCallback); return currentTransition; // it is already animating, queue callback in the next transition } else { if (!nextTransition) { nextTransition = new QueueingViewTransition(); } return nextTransition.addCallback(updateCallback); } // if no transition is active, start animating the new transition } else { currentTransition = new QueueingViewTransition(); currentTransition.addCallback(updateCallback); currentTransition.run(); // after it's done, execute any queued transition const doNext = () => { if (nextTransition) { currentTransition = nextTransition; nextTransition = null; currentTransition.run(); currentTransition.finished.finally(doNext); } else { currentTransition = null; } } currentTransition.finished.finally(doNext); return currentTransition; } } const doViewTransition = (updateCallback) => { let transition; if (document.startViewTransition) { transition = document.startViewTransition(updateCallback); } else { // fake view transition in firefox const done = promiseTry(updateCallback); transition = { updateCallbackDone: done, ready: done, finished: done, skipTransition: () => {} }; } return transition; } let nextQueueId = 0; class QueueingViewTransition { #id = nextQueueId++; #updateCallbackDone = Promise.withResolvers(); #ready = Promise.withResolvers(); #finished = Promise.withResolvers(); #callbacks = []; #activeViewTransition = null; get id() { return this.#id; } // transition is running isRunning = false; // callbacks are complete, animation will start isReady = false; // animation is complete isFinished = false; constructor() { this.ready.finally(() => this.isReady = true); this.finished.finally(() => { this.isFinished = true; }); } addCallback(updateCallback) { if (typeof updateCallback !== 'function') throw new Error('updateCallback must be a function'); if (this.isReady) throw new Error('view transition already started'); this.#callbacks.push(updateCallback); return this; } run(skipTransition = false) { // already running if (this.isRunning) return; this.isRunning = true; // execute callbacks in order in case later ones depend on DOM changes of earlier ones // but do it async to allow callbacks to be added until animation starts const doNext = () => { if (this.#callbacks.length) { const callback = this.#callbacks.shift(); return promiseTry(callback).then(doNext); } }; const callback = () => { return doNext().then(this.#updateCallbackDone.resolve, this.#updateCallbackDone.reject); }; // jump to the end if (skipTransition) { callback() .then(this.#ready.resolve, this.#ready.reject) .then(this.#finished.resolve, this.#finished.reject); // start animating } else { this.#activeViewTransition = doViewTransition(callback); this.#activeViewTransition.ready.then(this.#ready.resolve, this.#ready.reject); this.#activeViewTransition.finished.then(this.#finished.resolve, this.#finished.reject); } } // callbacks have fulfilled their promise get updateCallbackDone() { return this.#updateCallbackDone.promise } // animation is about to start get ready() { return this.#ready.promise } // animation has completed get finished() { return this.#finished.promise } skipTransition() { if (this.#activeViewTransition) { this.#activeViewTransition.skipTransition(); } else { this.run(true); } } } // polyfill function promiseTry(fn) { if (Promise.try) return Promise.try(fn); return new Promise(function(resolve) { resolve(fn()); }); } ================================================ FILE: public/blog/articles/2025-06-12-view-transitions/example6/index.html ================================================ Example 6
================================================ FILE: public/blog/articles/2025-06-12-view-transitions/example6/lib/html.js ================================================ class Html extends String { } /** * tag a string as html not to be encoded * @param {string} str * @returns {string} */ export const htmlRaw = str => new Html(str); /** * entity encode a string as html * @param {*} value The value to encode * @returns {string} */ export const htmlEncode = (value) => { // avoid double-encoding the same string if (value instanceof Html) { return value; } else { // https://stackoverflow.com/a/57448862/20980 return htmlRaw( String(value).replace(/[&<>'"]/g, tag => ({ '&': '&', '<': '<', '>': '>', "'": ''', '"': '"' }[tag])) ); } } /** * html tagged template literal, auto-encodes entities */ export const html = (strings, ...values) => htmlRaw(String.raw({ raw: strings }, ...values.map(htmlEncode))); ================================================ FILE: public/blog/articles/2025-06-12-view-transitions/example6/lib/view-route.js ================================================ export const routerEvents = new EventTarget(); // update routes on popstate (browser back/forward) export const handlePopState = (e) => { routerEvents.dispatchEvent(new PopStateEvent('popstate', { state: e.state })); } window.addEventListener('popstate', handlePopState); const baseURL = new URL(window.originalHref || document.URL); const basePath = baseURL.pathname.slice(0, baseURL.pathname.lastIndexOf('/')); /** * Usage: *

hello

= only match / or /index.html and show the text "hello" * = match every route below / (e.g. for site navigation) * = match #/todos/:id * = match if no other route matches within the same parent node * * routechange event contains detail.matches, the array of matched parts of the regex */ customElements.define('view-route', class extends HTMLElement { #matches = []; /** is this route currently active */ get isActive() { return !!this.#matches?.length; } /** array of matched parts of the regex */ get matches() { return this.#matches; } connectedCallback() { this.style.display = 'contents'; routerEvents.addEventListener('popstate', this); this.update(); } disconnectedCallback() { routerEvents.removeEventListener('popstate', this); } handleEvent(e) { this.update(); } static get observedAttributes() { return ['path', 'exact']; } attributeChangedCallback() { this.update(); } update() { let path = this.getAttribute('path') || '/'; // prepend absolute paths with the base path of the document (SPA behavior) if (path.startsWith('/')) path = basePath + path; const exact = this.hasAttribute('exact'); this.setMatches(this.matchesRoute(path, exact) || []); if (this.isActive) { this.dispatchEvent(new CustomEvent('routechange', { detail: this.matches, bubbles: true })); } } setMatches(matches) { this.#matches = matches; this.style.display = this.isActive ? 'contents' : 'none'; } matchesRoute(path, exact) { let matches; // '*' triggers fallback route if no other route matches if (path === '*') { const activeRoutes = Array.from( this.parentNode.getElementsByTagName('view-route') ).filter(_ => _.isActive); if (!activeRoutes.length) matches = ['*']; // normal routes } else { const regex = new RegExp(`^${path.replaceAll('/', '\\/')}${exact ? '$' : ''}`, 'gi'); const relativeUrl = location.pathname + location.search + location.hash; matches = regex.exec(relativeUrl); } return matches; } }); const handleLinkClick = (e) => { const a = e.target.closest('a'); if (a && a.href) { e.preventDefault(); const anchorUrl = new URL(a.href); const pageUrl = basePath + anchorUrl.pathname + anchorUrl.search + anchorUrl.hash; routerEvents.dispatchEvent(new CustomEvent('navigate', { detail: { url: pageUrl, a }})); } } const handleNavigate = (e) => { pushState(null, null, e.detail.url); } /** * intercept link navigation for all links inside root, * and do single-page navigation using pushState instead. * @param {HTMLElement} root */ export const interceptNavigation = (root) => { root.addEventListener('click', handleLinkClick); // by default, navigate events cause pushState() calls // add capturing listener to routerEvents before interceptNavigation() to prevent routerEvents.addEventListener('navigate', handleNavigate); } /** * Navigate to a new state and update routes * @param {*} state * @param {*} unused * @param {*} url */ export const pushState = (state, unused, url) => { history.pushState(state, unused, url); routerEvents.dispatchEvent(new PopStateEvent('popstate', { state })); } ================================================ FILE: public/blog/articles/2025-06-12-view-transitions/example6/lib/view-transition.js ================================================ let nextVTId = 0; customElements.define('view-transition', class extends HTMLElement { #defaultName = 'VT_' + nextVTId++; get name() { return this.getAttribute('name') } set name(v) { this.setAttribute('name', v); } static get observedAttributes() { return ['name'] } attributeChangedCallback() { this.update(); } connectedCallback() { this.update(); } disconnectedCallback() { this.updateShadowRule(false); } update() { this.style.display = 'block'; this.style.viewTransitionName = this.name || this.#defaultName; this.updateShadowRule(); } updateShadowRule(insert = true) { // if this is inside a shadow dom, make it visible to light dom view transitions // by setting view-transition-name on a shadow part from a light dom stylesheet if (this.getRootNode() instanceof ShadowRoot) { if (!this.hasAttribute('part')) { this.setAttribute('part', this.#defaultName); } const stylesheet = getTransitionStyleSheet(); const localName = this.getRootNode().host.localName; // delete the old rule const oldIndex = [...stylesheet.cssRules].findIndex(r => { const match = /^([^:]+)::part\(([^)]+)\)/.exec(r.selectorText); if (match && match[1] === localName && match[2] === this.getAttribute('part')) return true; }); if (oldIndex >= 0) stylesheet.deleteRule(oldIndex); // add the new rule if (insert) { stylesheet.insertRule( `${localName}::part(${this.getAttribute('part')}) { view-transition-name: ${this.style.viewTransitionName}; }`); } } } }); const getTransitionStyleSheet = () => { const adoptedStyleSheets = document.adoptedStyleSheets; let stylesheet = adoptedStyleSheets.find(s => s.id === 'view-transition'); if (!stylesheet) { stylesheet = new CSSStyleSheet(); stylesheet.id = 'view-transition'; adoptedStyleSheets.push(stylesheet); } return stylesheet; } export const transitionEvents = new EventTarget(); // the currently animating view transition let currentTransition = null; // the next transition to run (after currentTransition completes) let nextTransition = null; /** start a view transition or queue it for later if one is already animating */ export const startTransition = (updateCallback, transitionType) => { log('startTransition', { updateCallback, transitionType }); if (!updateCallback) updateCallback = () => {}; // a transition is active if (currentTransition && !currentTransition.isFinished) { // it is running callbacks, but not yet animating if (!currentTransition.isReady) { currentTransition.addCallback(updateCallback); return currentTransition; // it is already animating, queue callback in the next transition } else { if (!nextTransition) { nextTransition = new QueueingViewTransition(transitionType); } return nextTransition.addCallback(updateCallback); } // if no transition is active, start animating the new transition } else { currentTransition = new QueueingViewTransition(transitionType); currentTransition.addCallback(updateCallback); currentTransition.run(); // after it's done, execute any queued transition const doNext = () => { if (nextTransition) { currentTransition = nextTransition; nextTransition = null; currentTransition.run(); currentTransition.finished.finally(doNext); } else { currentTransition = null; } } currentTransition.finished.finally(doNext); return currentTransition; } } const doViewTransition = (updateCallback, transitionType) => { transitionEvents.dispatchEvent(new CustomEvent('transitionstart', { detail: { transitionType } })); let transition; if (document.startViewTransition) { transition = document.startViewTransition(updateCallback); } else { // fake view transition in firefox const done = promiseTry(updateCallback); transition = { updateCallbackDone: done, ready: done, finished: done, skipTransition: () => {} }; } transition.finished.finally(() => { transitionEvents.dispatchEvent(new CustomEvent('transitionend', { detail: { transitionType } })); }); return transition; } let nextQueueId = 0; class QueueingViewTransition { #id = nextQueueId++; #updateCallbackDone = Promise.withResolvers(); #ready = Promise.withResolvers(); #finished = Promise.withResolvers(); #callbacks = []; #activeViewTransition = null; #transitionType; get id() { return this.#id; } // transition is running isRunning = false; // callbacks are complete, animation will start isReady = false; // animation is complete isFinished = false; constructor(transitionType) { log('new QueueingViewTransition', { id: this.id, obj: this }); this.#transitionType = transitionType; this.ready.finally(() => this.isReady = true); this.finished.finally(() => { this.isFinished = true; log('QVT.finished', { id: this.id, obj: this, names: [...document.querySelectorAll('view-transition')] .filter(v => v.checkVisibility()) .map(v => v.style.viewTransitionName) }); }); } addCallback(updateCallback) { log('QVT.addCallback', { id: this.id, updateCallback, obj: this }); if (typeof updateCallback !== 'function') throw new Error('updateCallback must be a function'); if (this.isReady) throw new Error('view transition already started'); this.#callbacks.push(updateCallback); return this; } run(skipTransition = false) { log('QVT.run', { id: this.id, obj: this }); // already running if (this.isRunning) return; this.isRunning = true; // execute callbacks in order in case later ones depend on DOM changes of earlier ones // but do it async to allow callbacks to be added until animation starts const doNext = () => { if (this.#callbacks.length) { const callback = this.#callbacks.shift(); log('QVT.run > callback', { id: this.id, obj: this, callback }); return promiseTry(callback).then(doNext); } }; const callback = () => { return doNext().then(this.#updateCallbackDone.resolve, this.#updateCallbackDone.reject); }; // jump to the end if (skipTransition) { callback() .then(this.#ready.resolve, this.#ready.reject) .then(this.#finished.resolve, this.#finished.reject); // start animating } else { log('QVT.run > document.startViewTransition', { id: this.id, obj: this, callbacks: this.#callbacks.slice(), names: [...document.querySelectorAll('view-transition')] .filter(v => v.checkVisibility()) .map(v => v.style.viewTransitionName) }); this.#activeViewTransition = doViewTransition(callback, this.#transitionType); this.#activeViewTransition.ready.then(this.#ready.resolve, this.#ready.reject); this.#activeViewTransition.finished.then(this.#finished.resolve, this.#finished.reject); } } // callbacks have fulfilled their promise get updateCallbackDone() { return this.#updateCallbackDone.promise } // animation is about to start get ready() { return this.#ready.promise } // animation has completed get finished() { return this.#finished.promise } skipTransition() { if (this.#activeViewTransition) { this.#activeViewTransition.skipTransition(); } else { this.run(true); } } } // polyfill function promiseTry(fn) { if (Promise.try) return Promise.try(fn); return new Promise(function(resolve) { resolve(fn()); }); } function log(...args) { console.debug(...args); } ================================================ FILE: public/blog/articles/2025-06-12-view-transitions/example6/src/App.js ================================================ import '../lib/view-transition.js'; import './Home.js'; import './Details.js'; customElements.define('demo-app', class extends HTMLElement { connectedCallback() { this.innerHTML = ` `; } }); ================================================ FILE: public/blog/articles/2025-06-12-view-transitions/example6/src/Details.js ================================================ import { startTransition } from '../lib/view-transition.js'; import { html } from '../lib/html.js'; import { ChevronLeft } from './Icons.js'; import { fetchVideo, fetchVideoDetails } from './data.js'; import './Layout.js'; customElements.define('demo-details', class extends HTMLElement { connectedCallback() { this.innerHTML = `
`; const id = this.querySelector('view-route').matches?.groups?.id ?? null; this.update(id); this.addEventListener('routechange', this); } handleEvent(e) { if (e.type === 'routechange') { this.update(e.detail?.groups?.id); } } update(id) { const videoElem = this.querySelector('demo-video'); videoElem.innerHTML = ''; if (id) { startTransition(() => fetchVideo(id).then(video => { videoElem.innerHTML = html` `; })); this.querySelector('demo-video-details').update(id); } } }); customElements.define('demo-video-details', class extends HTMLElement { async update(id) { if (id) { const load = fetchVideoDetails(id); const wait = new Promise((resolve) => { setTimeout(resolve, 10, null); }); let video = await Promise.race([load, wait]); if (video) { this.innerHTML = videoInfo(video); } else { this.innerHTML = videoInfoFallback(); video = await load; // animate content in and fallback out startTransition(() => { this.innerHTML = videoInfo(video); }); } } else this.innerHTML = ''; } }); const videoInfoFallback = () => `
`; const videoInfo = (details) => html`

${details.title}

${details.description}

`; ================================================ FILE: public/blog/articles/2025-06-12-view-transitions/example6/src/Home.js ================================================ import { routerEvents } from "../lib/view-route.js"; import { startTransition } from "../lib/view-transition.js"; import './Layout.js'; import './Videos.js'; import { IconSearch } from "./Icons.js"; import { fetchVideos } from "./data.js"; customElements.define('demo-home', class extends HTMLElement { videos = []; searchInput = null; searchList = null; connectedCallback() { this.innerHTML = `
Loading...
`; this.searchInput = this.querySelector('demo-search-input'); this.searchInput.addEventListener('change', this.update.bind(this)); this.searchList = this.querySelector('demo-search-list'); fetchVideos().then(videos => { this.videos = videos; this.querySelector('div[slot=heading]').textContent = `${videos.length} Videos`; this.update(); }); // rerender after back navigation routerEvents.addEventListener('popstate', this); } handleEvent(e) { this.update(); } update() { const filteredVideos = filterVideos(this.videos, this.searchInput.text); this.searchList.update(filteredVideos, this.searchInput.text); } }); customElements.define('demo-search-input', class extends HTMLElement { #text = ''; get text() { return this.#text } set text(v) { if (this.#text !== v) { this.#text = v; this.update(); } } connectedCallback() { this.innerHTML = ` `; this.querySelector('input').addEventListener('input', e => { this.#text = e.target.value; this.dispatchEvent(new CustomEvent('change', { detail: e.target.value })); }); this.querySelector('form').addEventListener('submit', e => { e.preventDefault(); }); this.update(); } update() { this.querySelector('input').value = this.text; } }); customElements.define('demo-search-list', class extends HTMLElement { update(videos, text) { const filteredVideos = filterVideos(videos, text); startTransition(() => { this.innerHTML = `
${!filteredVideos.length ? ( `
No results
` ) : ''}
`; if (filteredVideos.length) { this.querySelector('.videos').replaceChildren(...filteredVideos.map(v => { const transition = document.createElement('view-transition'); transition.name = `list-video-${v.id}`; transition.append(document.createElement('demo-video')); transition.firstChild.update(v); return transition; })); } }); } }); function filterVideos(videos, query) { const keywords = query .toLowerCase() .split(" ") .filter((s) => s !== ""); if (keywords.length === 0) { return videos; } return videos.filter((video) => { const words = (video.title + " " + video.description) .toLowerCase() .split(" "); return keywords.every((kw) => words.some((w) => w.includes(kw))); }); } ================================================ FILE: public/blog/articles/2025-06-12-view-transitions/example6/src/Icons.js ================================================ export function ChevronLeft() { return (` `); } export function PauseIcon() { return (` `); } export function PlayIcon() { return (` `); } export function Heart({liked, animate}) { return (` ${liked ? ` ` : ` `} `); } export function IconSearch() { return (` `); } ================================================ FILE: public/blog/articles/2025-06-12-view-transitions/example6/src/Layout.js ================================================ import { transitionEvents } from "../lib/view-transition.js"; customElements.define('demo-page', class extends HTMLElement { constructor() { super(); this.attachShadow({ mode: 'open' }); this.shadowRoot.innerHTML = `
`; const viewTransition = this.shadowRoot.querySelector('view-transition'); transitionEvents.addEventListener('transitionstart', e => { const transitionType = e.detail?.transitionType; switch (transitionType) { case 'nav-back': case 'nav-forward': viewTransition.name = transitionType; break; } }); transitionEvents.addEventListener('transitionend', e => { viewTransition.name = 'nav'; }); } }); ================================================ FILE: public/blog/articles/2025-06-12-view-transitions/example6/src/LikeButton.js ================================================ import { Heart } from './Icons.js'; // A hack since we don't actually have a backend. // Unlike local state, this survives videos being filtered. const likedVideos = new Set(); customElements.define('demo-video-like', class extends HTMLElement { animate = false; connectedCallback() { this.replaceChildren(document.createElement('button')); this.update(); }; update() { const id = this.getAttribute('id'); const liked = likedVideos.has(id); const button = this.querySelector('button'); button.className = 'like-button ' + (liked ? 'liked' : ''); button.ariaLabel = liked ? 'Unsave' : 'Save'; button.innerHTML = Heart({liked, animate: this.animate}); button.onclick = () => { this.animate = true; likedVideos[liked ? 'delete' : 'add'](id); this.update(); this.animate = false; }; } }); ================================================ FILE: public/blog/articles/2025-06-12-view-transitions/example6/src/Videos.js ================================================ import { startTransition } from '../lib/view-transition.js'; import { PlayIcon, PauseIcon } from './Icons.js'; import './LikeButton.js'; customElements.define('demo-video', class extends HTMLElement { update(video) { this.innerHTML = ` `; } }); customElements.define('demo-video-controls', class extends HTMLElement { isPlaying = false; connectedCallback() { this.innerHTML = ` ${PlayIcon()} `; this.addEventListener('click', this); this.update(); } handleEvent(e) { startTransition(async () => { this.isPlaying = !this.isPlaying; this.update(); }); } update() { this.querySelector('span').innerHTML = this.isPlaying ? PauseIcon() : PlayIcon(); } }); ================================================ FILE: public/blog/articles/2025-06-12-view-transitions/example6/src/animations.css ================================================ /* Slide animations for details content */ ::view-transition-old(details-fallback):only-child { animation: 150ms ease-out both fade-out, 150ms ease-out both slide-down; } ::view-transition-new(details-content):only-child, ::view-transition-new(details-fallback):only-child { animation: 210ms ease-in 150ms both fade-in, 400ms ease-in both slide-up; } /* Animations for view transition classed added by transition type */ ::view-transition-old(nav-forward) { /* when sliding forward, the "old" page should slide out to left. */ animation: 150ms cubic-bezier(0.4, 0, 1, 1) both fade-out, 400ms cubic-bezier(0.4, 0, 0.2, 1) both slide-to-left; } ::view-transition-new(nav-forward) { /* when sliding forward, the "new" page should slide in from right. */ animation: 210ms cubic-bezier(0, 0, 0.2, 1) 150ms both fade-in, 400ms cubic-bezier(0.4, 0, 0.2, 1) both slide-from-right; } ::view-transition-old(nav-back) { /* when sliding back, the "old" page should slide out to right. */ animation: 150ms cubic-bezier(0.4, 0, 1, 1) both fade-out, 400ms cubic-bezier(0.4, 0, 0.2, 1) both slide-to-right; } ::view-transition-new(nav-back) { /* when sliding back, the "new" page should slide in from left. */ animation: 210ms cubic-bezier(0, 0, 0.2, 1) 150ms both fade-in, 400ms cubic-bezier(0.4, 0, 0.2, 1) both slide-from-left; } /* Keyframes to support our animations above. */ @keyframes slide-up { from { transform: translateY(10px); } to { transform: translateY(0); } } @keyframes slide-down { from { transform: translateY(0); } to { transform: translateY(10px); } } @keyframes fade-in { from { opacity: 0; } } @keyframes fade-out { to { opacity: 0; } } @keyframes slide-to-right { to { transform: translateX(50px); } } @keyframes slide-from-right { from { transform: translateX(50px); } to { transform: translateX(0); } } @keyframes slide-to-left { to { transform: translateX(-50px); } } @keyframes slide-from-left { from { transform: translateX(-50px); } to { transform: translateX(0); } } ================================================ FILE: public/blog/articles/2025-06-12-view-transitions/example6/src/data.js ================================================ const videos = [ { id: '1', title: 'First video', description: 'Video description', image: 'blue', }, { id: '2', title: 'Second video', description: 'Video description', image: 'red', }, { id: '3', title: 'Third video', description: 'Video description', image: 'green', }, { id: '4', title: 'Fourth video', description: 'Video description', image: 'purple', }, { id: '5', title: 'Fifth video', description: 'Video description', image: 'yellow', }, { id: '6', title: 'Sixth video', description: 'Video description', image: 'gray', }, ]; let videosCache = new Map(); let videoCache = new Map(); let videoDetailsCache = new Map(); const VIDEO_DELAY = 1; const VIDEO_DETAILS_DELAY = 1000; export function fetchVideos() { if (videosCache.has(0)) { return videosCache.get(0); } const promise = new Promise((resolve) => { setTimeout(() => { resolve(videos); }, VIDEO_DELAY); }); videosCache.set(0, promise); return promise; } export function fetchVideo(id) { if (videoCache.has(id)) { return videoCache.get(id); } const promise = new Promise((resolve) => { setTimeout(() => { resolve(videos.find((video) => video.id === id)); }, VIDEO_DELAY); }); videoCache.set(id, promise); return promise; } export function fetchVideoDetails(id) { if (videoDetailsCache.has(id)) { return videoDetailsCache.get(id); } const promise = new Promise((resolve) => { setTimeout(() => { resolve(videos.find((video) => video.id === id)); }, VIDEO_DETAILS_DELAY); }); videoDetailsCache.set(id, promise); return promise; } ================================================ FILE: public/blog/articles/2025-06-12-view-transitions/example6/src/index.js ================================================ import { interceptNavigation, routerEvents, handlePopState, pushState } from '../lib/view-route.js'; import { startTransition } from '../lib/view-transition.js'; import './App.js'; const app = () => { // intercept default router behavior to make it animate view transitions routerEvents.addEventListener('navigate', (e) => { e.stopImmediatePropagation(); const { url, a } = e.detail; const isBackNav = a?.classList?.contains('back'); const transitionType = isBackNav ? 'nav-back' : 'nav-forward'; startTransition( () => { pushState(transitionType, null, url); // give routes time to render before snapshotting return new Promise(resolve => setTimeout(resolve, 10)); }, transitionType); }, { capture: true }); // intercept popstate to animate back/forward page navigation window.removeEventListener('popstate', handlePopState); window.addEventListener('popstate', (e) => { startTransition(() => handlePopState(e), e.state); }); const root = document.getElementById('root'); root.innerHTML = ``; interceptNavigation(root); } document.addEventListener('DOMContentLoaded', app); ================================================ FILE: public/blog/articles/2025-06-12-view-transitions/example6/src/styles.css ================================================ * { box-sizing: border-box; } body { font-family: sans-serif; margin: 20px; padding: 0; } h1 { margin-top: 0; font-size: 22px; } h2 { margin-top: 0; font-size: 20px; } h3 { margin-top: 0; font-size: 18px; } h4 { margin-top: 0; font-size: 16px; } h5 { margin-top: 0; font-size: 14px; } h6 { margin-top: 0; font-size: 12px; } code { font-size: 1.2em; } ul { padding-inline-start: 20px; } @font-face { font-family: Optimistic Text; src: url(https://react.dev/fonts/Optimistic_Text_W_Rg.woff2) format("woff2"); font-weight: 400; font-style: normal; font-display: swap; } @font-face { font-family: Optimistic Text; src: url(https://react.dev/fonts/Optimistic_Text_W_Md.woff2) format("woff2"); font-weight: 500; font-style: normal; font-display: swap; } @font-face { font-family: Optimistic Text; src: url(https://react.dev/fonts/Optimistic_Text_W_Bd.woff2) format("woff2"); font-weight: 600; font-style: normal; font-display: swap; } @font-face { font-family: Optimistic Text; src: url(https://react.dev/fonts/Optimistic_Text_W_Bd.woff2) format("woff2"); font-weight: 700; font-style: normal; font-display: swap; } * { box-sizing: border-box; } html { background-image: url(https://react.dev/images/meta-gradient-dark.png); background-size: 100%; background-position: -100%; background-color: rgb(64 71 86); background-repeat: no-repeat; height: 100%; width: 100%; } body { font-family: Optimistic Text, -apple-system, ui-sans-serif, system-ui, sans-serif, Apple Color Emoji, Segoe UI Emoji, Segoe UI Symbol, Noto Color Emoji; padding: 10px 0 10px 0; margin: 0; display: flex; justify-content: center; } #root { flex: 1 1; height: auto; background-color: #fff; border-radius: 10px; max-width: 450px; min-height: 600px; padding-bottom: 10px; } h1 { margin-top: 0; font-size: 22px; } h2 { margin-top: 0; font-size: 20px; } h3 { margin-top: 0; font-size: 18px; } h4 { margin-top: 0; font-size: 16px; } h5 { margin-top: 0; font-size: 14px; } h6 { margin-top: 0; font-size: 12px; } code { font-size: 1.2em; } ul { padding-inline-start: 20px; } .sr-only { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; border-width: 0; } .absolute { position: absolute; } .overflow-visible { overflow: visible; } .visible { overflow: visible; } .fit { width: fit-content; } /* Layout */ .page { display: flex; flex-direction: column; height: 100%; } .top-hero { height: 200px; display: flex; justify-content: center; align-items: center; background-image: conic-gradient( from 90deg at -10% 100%, #2b303b 0deg, #2b303b 90deg, #16181d 1turn ); } .bottom { flex: 1; overflow: auto; } .top-nav { display: flex; align-items: center; justify-content: space-between; margin-bottom: 0; padding: 0 12px; top: 0; width: 100%; height: 44px; color: #23272f; font-weight: 700; font-size: 20px; z-index: 100; cursor: default; } .content { padding: 0 12px; margin-top: 4px; } .loader { color: #23272f; font-size: 3px; width: 1em; margin-right: 18px; height: 1em; border-radius: 50%; position: relative; text-indent: -9999em; animation: loading-spinner 1.3s infinite linear; animation-delay: 200ms; transform: translateZ(0); } @keyframes loading-spinner { 0%, 100% { box-shadow: 0 -3em 0 0.2em, 2em -2em 0 0em, 3em 0 0 -1em, 2em 2em 0 -1em, 0 3em 0 -1em, -2em 2em 0 -1em, -3em 0 0 -1em, -2em -2em 0 0; } 12.5% { box-shadow: 0 -3em 0 0, 2em -2em 0 0.2em, 3em 0 0 0, 2em 2em 0 -1em, 0 3em 0 -1em, -2em 2em 0 -1em, -3em 0 0 -1em, -2em -2em 0 -1em; } 25% { box-shadow: 0 -3em 0 -0.5em, 2em -2em 0 0, 3em 0 0 0.2em, 2em 2em 0 0, 0 3em 0 -1em, -2em 2em 0 -1em, -3em 0 0 -1em, -2em -2em 0 -1em; } 37.5% { box-shadow: 0 -3em 0 -1em, 2em -2em 0 -1em, 3em 0em 0 0, 2em 2em 0 0.2em, 0 3em 0 0em, -2em 2em 0 -1em, -3em 0em 0 -1em, -2em -2em 0 -1em; } 50% { box-shadow: 0 -3em 0 -1em, 2em -2em 0 -1em, 3em 0 0 -1em, 2em 2em 0 0em, 0 3em 0 0.2em, -2em 2em 0 0, -3em 0em 0 -1em, -2em -2em 0 -1em; } 62.5% { box-shadow: 0 -3em 0 -1em, 2em -2em 0 -1em, 3em 0 0 -1em, 2em 2em 0 -1em, 0 3em 0 0, -2em 2em 0 0.2em, -3em 0 0 0, -2em -2em 0 -1em; } 75% { box-shadow: 0em -3em 0 -1em, 2em -2em 0 -1em, 3em 0em 0 -1em, 2em 2em 0 -1em, 0 3em 0 -1em, -2em 2em 0 0, -3em 0em 0 0.2em, -2em -2em 0 0; } 87.5% { box-shadow: 0em -3em 0 0, 2em -2em 0 -1em, 3em 0 0 -1em, 2em 2em 0 -1em, 0 3em 0 -1em, -2em 2em 0 0, -3em 0em 0 0, -2em -2em 0 0.2em; } } /* LikeButton */ .like-button { outline-offset: 2px; position: relative; display: flex; align-items: center; justify-content: center; width: 2.5rem; height: 2.5rem; cursor: pointer; border-radius: 9999px; border: none; outline: none 2px; color: #5e687e; background: none; } .like-button:focus { color: #a6423a; background-color: rgba(166, 66, 58, .05); } .like-button:active { color: #a6423a; background-color: rgba(166, 66, 58, .05); transform: scaleX(0.95) scaleY(0.95); } .like-button:hover { background-color: #f6f7f9; } .like-button.liked { color: #a6423a; } /* Icons */ @keyframes circle { 0% { transform: scale(0); stroke-width: 16px; } 50% { transform: scale(.5); stroke-width: 16px; } to { transform: scale(1); stroke-width: 0; } } .circle { color: rgba(166, 66, 58, .5); transform-origin: center; transition-property: all; transition-duration: .15s; transition-timing-function: cubic-bezier(.4,0,.2,1); } .circle.liked.animate { animation: circle .3s forwards; } .heart { width: 1.5rem; height: 1.5rem; } .heart.liked { transform-origin: center; transition-property: all; transition-duration: .15s; transition-timing-function: cubic-bezier(.4, 0, .2, 1); } .heart.liked.animate { animation: scale .35s ease-in-out forwards; } .control-icon { color: hsla(0, 0%, 100%, .5); filter: drop-shadow(0 20px 13px rgba(0, 0, 0, .03)) drop-shadow(0 8px 5px rgba(0, 0, 0, .08)); } .chevron-left { margin-top: 2px; rotate: 90deg; } /* Video */ .thumbnail { position: relative; aspect-ratio: 16 / 9; display: flex; overflow: hidden; flex-direction: column; justify-content: center; align-items: center; border-radius: 0.5rem; outline-offset: 2px; width: 8rem; vertical-align: middle; background-color: #ffffff; background-size: cover; -webkit-user-select: none; user-select: none; } .thumbnail.blue { background-image: conic-gradient(at top right, #c76a15, #087ea4, #2b3491); } .thumbnail.red { background-image: conic-gradient(at top right, #c76a15, #a6423a, #2b3491); } .thumbnail.green { background-image: conic-gradient(at top right, #c76a15, #388f7f, #2b3491); } .thumbnail.purple { background-image: conic-gradient(at top right, #c76a15, #575fb7, #2b3491); } .thumbnail.yellow { background-image: conic-gradient(at top right, #c76a15, #FABD62, #2b3491); } .thumbnail.gray { background-image: conic-gradient(at top right, #c76a15, #4E5769, #2b3491); } .video { display: flex; flex-direction: row; gap: 0.75rem; align-items: center; } .video .link { display: flex; flex-direction: row; flex: 1 1 0; gap: 0.125rem; outline-offset: 4px; cursor: pointer; } .video .info { display: flex; flex-direction: column; justify-content: center; margin-left: 8px; gap: 0.125rem; } .video .info:hover { text-decoration: underline; } .video-title { font-size: 15px; line-height: 1.25; font-weight: 700; color: #23272f; } .video-description { color: #5e687e; font-size: 13px; } /* Details */ .details .thumbnail { position: relative; aspect-ratio: 16 / 9; display: flex; overflow: hidden; flex-direction: column; justify-content: center; align-items: center; border-radius: 0.5rem; outline-offset: 2px; width: 100%; vertical-align: middle; background-color: #ffffff; background-size: cover; -webkit-user-select: none; user-select: none; } .video-details-title { margin-top: 8px; } .video-details-speaker { display: flex; gap: 8px; margin-top: 10px } .back { display: flex; align-items: center; margin-left: -5px; cursor: pointer; } .back:hover { text-decoration: underline; } .info-title { font-size: 1.5rem; font-weight: 700; line-height: 1.25; margin: 8px 0 0 0 ; } .info-description { margin: 8px 0 0 0; } .controls { cursor: pointer; } .fallback { background: #f6f7f8 linear-gradient(to right, #e6e6e6 5%, #cccccc 25%, #e6e6e6 35%) no-repeat; background-size: 800px 104px; display: block; line-height: 1.25; margin: 8px 0 0 0; border-radius: 5px; overflow: hidden; animation: 1s linear 1s infinite shimmer; animation-delay: 300ms; animation-duration: 1s; animation-fill-mode: forwards; animation-iteration-count: infinite; animation-name: shimmer; animation-timing-function: linear; } .fallback.title { width: 130px; height: 30px; } .fallback.description { width: 150px; height: 21px; } @keyframes shimmer { 0% { background-position: -468px 0; } 100% { background-position: 468px 0; } } .search { margin-bottom: 10px; } .search-input { width: 100%; position: relative; } .search-icon { position: absolute; top: 0; bottom: 0; inset-inline-start: 0; display: flex; align-items: center; padding-inline-start: 1rem; pointer-events: none; color: #99a1b3; } .search-input input { display: flex; padding-inline-start: 2.75rem; padding-top: 10px; padding-bottom: 10px; width: 100%; text-align: start; background-color: rgb(235 236 240); outline: 2px solid transparent; cursor: pointer; border: none; align-items: center; color: rgb(35 39 47); border-radius: 9999px; vertical-align: middle; font-size: 15px; } .search-input input:hover, .search-input input:active { background-color: rgb(235 236 240/ 0.8); color: rgb(35 39 47/ 0.8); } /* Home */ .video-list { position: relative; } .video-list .videos { display: flex; flex-direction: column; gap: 1rem; overflow-y: auto; height: 100%; } /* extra styles: */ /* make this class work for actual elements */ .link { text-decoration: none; color: inherit; } ================================================ FILE: public/blog/articles/2025-06-12-view-transitions/index.html ================================================ Bringing React's <ViewTransition> to vanilla JS A vanilla superhero girl breaking through comic cell walls.

Bringing React's <ViewTransition> to vanilla JS

I like React. I really do. It is the default answer for modern web development, and it is that answer for a reason. Generally when React adds a feature it is well thought through, within the React system of thinking. My one criticism is that React by its nature overthinks things, that dumber and simpler solutions would often be on the whole ... better. Less magic, more predictable.

So when I port framework features to vanilla JS, don't take this as a slight of that framework. It is meant as an exploration of what dumber and simpler solutions might look like, when built on the ground floor of the web's platform instead of the lofty altitudes of big frameworks. It is a great way to learn.

Which brings me of course to today's topic: view transitions, and how to implement them.

View Transitions 101

Let's start with the basics: what is a view transition?

In a supporting browser, what you'll see when you click is a square smoothly transitioning between blue and orange on every button click. By supported browser I mean Chrome, Edge or Safari, but sadly not yet Firefox, although they're working on it! In Firefox you'll see the change, but applied immediately without the animation.

At the code level, it looks something like this:

How this works is that the browser takes a snapshot of the page when we call document.startViewTransition(), takes another snapshot after the callback passed to it is done (or the promise it returns fulfills), and then figures out how to smoothly animate between the two snapshots, using a fade by default.

A very nice thing is that by putting a view-transition-name style on an element we can make it transition independently from the rest of the page, and we can control that transition through CSS.

Now we can see a second square sliding in on the first click, and fading out on the second.

That's enough view transition basics for now. If you're curious for more, you can learn the rest in the chrome developer documentation.

Here comes trouble

Up to this point, we've gotten the fair weather version of view transitions, but there are paper cuts.

  • Firefox doesn't support view transitions at all, so we have to feature-detect.
  • There is only one actual current View Transitions standard, level 1, but most of the online tutorials talk about the unfinalized level 2.
  • If there are duplicate values of view-transition-name anywhere on the page, the animations disappear in a puff of duplicate element error smoke.
  • As always, there's a thing about shadow DOM, but more on that later.
  • Starting a new view transition when one is already running skips to the end of the previous one, bringing the smooth user experience to a jarring end.
  • User input is blocked while the view is transitioning, causing frustration when clicks are ignored.
  • The document.startViewTransition() function only accepts a single callback that returns a single promise.

It is the last one that really spells trouble. In a larger single-page web application we'll typically find a central routing layer that triggers a number of asynchronous updates every time the route changes. Wrapping those asynchronous updates into a single promise can be a challenge, as is finding the right place to "slot in" a call to document.startViewTransition().

Also, we probably don't even want to wait for all of the asynchronous updates to complete. Leaving the application in an interactive state in between two smaller view transitions is better than bundling it all together into one ponderous picture perfect transition animation.

What React did

React being React they solve those problems through magic, through exceeding cleverness. You can read up on their approach to view transitions, but distilling it down it becomes this:

  • Anything that should take part separately in a view transition is wrapped in a <ViewTransition> component.
  • React will choose unique view-transition-name style values, which DOM elements to set them on, and when to set them. This can be controlled through the <ViewTransition> name and key props.
  • Any updates that should become part of a view transition are wrapped in a startTransition() call.
  • React automatically figures out when to call document.startViewTransition(), and what updates to put inside the callback. It also cleverly avoids starting new transitions when one is already running, so startTransition() can be called from multiple places safely. Oh, and by the way, it feature detects, obviously.

When you do all of that, you get magic.

A React view transition demo

Good luck figuring out how it works, or how to troubleshoot when the magic loses its shine. But that is the bar, that is the lofty goal of user experience to reach with a dumber and simpler reimagining as vanilla JS. So let's get cooking.

A fresh start

Our starting point is a barebones implementation of a startTransition() function to replace what React's startTransition() does. It will fall back to non-animated transitions if our browser doesn't support document.startViewTransition.

While that takes care of feature-detecting, we can still run into timing issues. For example, let's say that instead of toggling we were switching routes, and the second route needs to load data prior to animating in.

So with HTML like this:


    <p><button>Navigate</button></p>
    <div id="route1" class="route"></div>
    <div id="route2" class="route"></div>
        

We might intuitively choose to do something like this:

But, as you see when trying it out, it doesn't work. Because the startTransition() calls end up overlapping each other, the animation is interrupted, and we get a jarring experience. While this toy example can be made to work by tuning delays, in the real world those same delays are network-based, so there's no timing-based solution. We also can't solve this by bundling everything into one single big view transition, because that would imply blocking user input while a network request completes, which would be a bad user experience.

React solves all of this in the typical React way. It will smartly choose how to batch work into successive calls to document.startViewTransition(). It will take into account where something loads lazily, as in the previous example, and batch the work of animating in the content for the fallback in a separate view transition.

Taking a queue

Distilling that approach to its essence, the really useful part of React's solution is the queueing and batching of work. Any call to startTransition() that occurs while a view transition is running should be queued until after the transition completes, and nested calls should have all their updates batched together.

The QueueingViewTransition implementation is a straightforward batching of callbacks, and a single call to document.startViewTransition() that executes them in order. It is not included in the text of this article for brevity's sake, but linked at the bottom instead.

Applying that queueing solution on top of the previous example's unchanged code, we suddenly see the magic of clean view transitions between dynamically loading routes.

Back to the top

So as I was saying at the top, I like porting framework features to vanilla JS as a way of learning and exploring dumber and simpler solutions. Which brings me to the playground for that learning, a full port of React's tour-de-force <ViewTransition> example to vanilla web code.

The full code of this example is on GitHub. Arguably the 300 lines of code in the lib/ folder of that example constitute a mini-framework, but fascinating to me is that you can get so much mileage out of such a small amount of library code, with the resulting single-page application being more or less the same number of lines as the React original.

That example also shows how to do a purely client-side router with clean URLs using pushState(). This blog post has however gone too long already, so I'll leave that for another time.

One more thing

Oh yeah, I promised to talk about the thing with shadow DOM, and I promised a custom element. Here is the thing with shadow DOM: when document.startViewTransition() is called from the light DOM, it cannot see elements inside the shadow DOM that need to transition independently, unless those elements are exposed as DOM parts and a view-transition-name style is set on them in the light DOM.

If the solution to that intrigues you, it's in the GitHub example repo as well as a <view-transition> custom element. If that sounds like a bunch of mumbo jumbo instead, join the club. Just one more reason to avoid shadow DOM.

================================================ FILE: public/blog/articles/2025-06-25-routing/example1/app.js ================================================ import { routerEvents, interceptNavigation } from './view-route.js'; customElements.define('demo-app', class extends HTMLElement { #route = '/'; constructor() { super(); interceptNavigation(document.body); routerEvents.addEventListener('navigate', (e) => { this.#route = e.detail.url; this.update(); }); } connectedCallback() { this.update(); } update() { if (this.#route === '/') { this.innerHTML = 'This is the homepage. Go to the details page.'; } else if (this.#route === '/details') { this.innerHTML = 'This is the details page. Go to the home page.'; } else { this.innerHTML = `The page ${this.#route} does not exist. Go to the home page.`; } this.innerHTML += `
Current route: ${this.#route}`; } }); ================================================ FILE: public/blog/articles/2025-06-25-routing/example1/index.html ================================================ Example 1 ================================================ FILE: public/blog/articles/2025-06-25-routing/example1/view-route.js ================================================ export const routerEvents = new EventTarget(); export const interceptNavigation = (root) => { // convert link clicks to navigate events root.addEventListener('click', handleLinkClick); // convert navigate events to pushState() calls routerEvents.addEventListener('navigate', handleNavigate); } const handleLinkClick = (e) => { const a = e.target.closest('a'); if (a && a.href) { e.preventDefault(); const anchorUrl = new URL(a.href); const pageUrl = anchorUrl.pathname + anchorUrl.search + anchorUrl.hash; routerEvents.dispatchEvent(new CustomEvent('navigate', { detail: { url: pageUrl, a }})); } } const handleNavigate = (e) => { history.pushState(null, null, e.detail.url); } ================================================ FILE: public/blog/articles/2025-06-25-routing/example2/app.js ================================================ import { routerEvents, interceptNavigation, matchesRoute } from './view-route.js'; customElements.define('demo-app', class extends HTMLElement { #route = '/'; constructor() { super(); interceptNavigation(document.body); routerEvents.addEventListener('popstate', (e) => { const matches = matchesRoute('/details') || matchesRoute('/'); this.#route = matches?.[1]; this.update(); }); } connectedCallback() { this.update(); } update() { if (this.#route === '/') { this.innerHTML = 'This is the homepage. Go to the details page.'; } else if (this.#route === '/details') { this.innerHTML = 'This is the details page. Go to the home page.'; } else { this.innerHTML = `The page ${this.#route} does not exist. Go to the home page.`; } this.innerHTML += `
Current route: ${this.#route}`; } }); ================================================ FILE: public/blog/articles/2025-06-25-routing/example2/index.html ================================================ Example 2 ================================================ FILE: public/blog/articles/2025-06-25-routing/example2/view-route-partial.js ================================================ const handleNavigate = (e) => { history.pushState(null, null, e.detail.url); routerEvents.dispatchEvent(new PopStateEvent('popstate')); } // update routes on popstate (browser back/forward) export const handlePopState = (e) => { routerEvents.dispatchEvent(new PopStateEvent('popstate', { state: e.state })); } window.addEventListener('popstate', handlePopState); ================================================ FILE: public/blog/articles/2025-06-25-routing/example2/view-route-partial2.js ================================================ // ... // all routes will be relative to the document's base path const baseURL = new URL(window.originalHref || document.URL); const basePath = baseURL.pathname.slice(0, baseURL.pathname.lastIndexOf('/')); // returns an array of regex matches for matched routes, or null export const matchesRoute = (path) => { const fullPath = basePath + '(' + path + ')'; const regex = new RegExp(`^${fullPath.replaceAll('/', '\\/')}`, 'gi'); const relativeUrl = location.pathname; return regex.exec(relativeUrl); } ================================================ FILE: public/blog/articles/2025-06-25-routing/example2/view-route.js ================================================ export const routerEvents = new EventTarget(); // all routes will be relative to the document's base path const baseURL = new URL(window.originalHref || document.URL); const basePath = baseURL.pathname.slice(0, baseURL.pathname.lastIndexOf('/')); export const interceptNavigation = (root) => { // convert link clicks to navigate events root.addEventListener('click', handleLinkClick); // convert navigate events to pushState() calls routerEvents.addEventListener('navigate', handleNavigate); } const handleLinkClick = (e) => { const a = e.target.closest('a'); if (a && a.href) { e.preventDefault(); const anchorUrl = new URL(a.href); const pageUrl = basePath + anchorUrl.pathname + anchorUrl.search + anchorUrl.hash; routerEvents.dispatchEvent(new CustomEvent('navigate', { detail: { url: pageUrl, a }})); } } const handleNavigate = (e) => { history.pushState(null, null, e.detail.url); routerEvents.dispatchEvent(new PopStateEvent('popstate')); } // update routes on popstate (browser back/forward) export const handlePopState = (e) => { routerEvents.dispatchEvent(new PopStateEvent('popstate', { state: e.state })); } window.addEventListener('popstate', handlePopState); // returns an array of regex matches for matched routes, or null export const matchesRoute = (path) => { const fullPath = path.startsWith('/') ? basePath + '(' + path + ')' : '(' + path + ')'; const regex = new RegExp(`^${fullPath.replaceAll('/', '\\/')}`, 'gi'); const relativeUrl = location.pathname; return regex.exec(relativeUrl); } ================================================ FILE: public/blog/articles/2025-06-25-routing/example3/app.js ================================================ import { interceptNavigation } from './view-route.js'; customElements.define('demo-app', class extends HTMLElement { constructor() { super(); interceptNavigation(document.body); } connectedCallback() { this.innerHTML = ` This is the homepage. Go to the details page, or travel a path of mystery. This is the details page. Go to the home page. The page does not exist. Go to the home page. ` } }); ================================================ FILE: public/blog/articles/2025-06-25-routing/example3/index.html ================================================ Example 3 ================================================ FILE: public/blog/articles/2025-06-25-routing/example3/view-route-partial.js ================================================ // ... customElements.define('view-route', class extends HTMLElement { #matches = []; get isActive() { return !!this.#matches?.length; } get matches() { return this.#matches; } set matches(v) { this.#matches = v; this.style.display = this.isActive ? 'contents' : 'none'; if (this.isActive) { this.dispatchEvent(new CustomEvent('routechange', { detail: v, bubbles: true })); } } connectedCallback() { routerEvents.addEventListener('popstate', this); this.update(); } disconnectedCallback() { routerEvents.removeEventListener('popstate', this); } handleEvent(e) { this.update(); } static get observedAttributes() { return ['path']; } attributeChangedCallback() { this.update(); } update() { const path = this.getAttribute('path') || '/'; this.matches = this.matchesRoute(path) || []; } matchesRoute(path) { // '*' triggers fallback route if no other route on the same DOM level matches if (path === '*') { const activeRoutes = Array.from(this.parentNode.getElementsByTagName('view-route')).filter(_ => _.isActive); if (!activeRoutes.length) return [location.pathname, '*']; // normal routes } else { return matchesRoute(path); } return null; } }); ================================================ FILE: public/blog/articles/2025-06-25-routing/example3/view-route.js ================================================ export const routerEvents = new EventTarget(); // all routes will be relative to the document's base path const baseURL = new URL(window.originalHref || document.URL); const basePath = baseURL.pathname.slice(0, baseURL.pathname.lastIndexOf('/')); export const interceptNavigation = (root) => { // convert link clicks to navigate events root.addEventListener('click', handleLinkClick); // convert navigate events to pushState() calls routerEvents.addEventListener('navigate', handleNavigate); } const handleLinkClick = (e) => { const a = e.target.closest('a'); if (a && a.href) { e.preventDefault(); const anchorUrl = new URL(a.href); const pageUrl = basePath + anchorUrl.pathname + anchorUrl.search + anchorUrl.hash; routerEvents.dispatchEvent(new CustomEvent('navigate', { detail: { url: pageUrl, a }})); } } const handleNavigate = (e) => { history.pushState(null, null, e.detail.url); routerEvents.dispatchEvent(new PopStateEvent('popstate')); } // update routes on popstate (browser back/forward) export const handlePopState = (e) => { routerEvents.dispatchEvent(new PopStateEvent('popstate', { state: e.state })); } window.addEventListener('popstate', handlePopState); // returns an array of regex matches for matched routes, or null export const matchesRoute = (path) => { const fullPath = path.startsWith('/') ? basePath + '(' + path + ')' : '(' + path + ')'; const regex = new RegExp(`^${fullPath.replaceAll('/', '\\/')}`, 'gi'); const relativeUrl = location.pathname; return regex.exec(relativeUrl); } customElements.define('view-route', class extends HTMLElement { #matches = []; get isActive() { return !!this.#matches?.length; } get matches() { return this.#matches; } set matches(v) { this.#matches = v; this.style.display = this.isActive ? 'contents' : 'none'; if (this.isActive) { this.dispatchEvent(new CustomEvent('routechange', { detail: v, bubbles: true })); } } connectedCallback() { routerEvents.addEventListener('popstate', this); this.update(); } disconnectedCallback() { routerEvents.removeEventListener('popstate', this); } handleEvent(e) { this.update(); } static get observedAttributes() { return ['path']; } attributeChangedCallback() { this.update(); } update() { const path = this.getAttribute('path') || '/'; this.matches = this.matchesRoute(path) || []; } matchesRoute(path) { // '*' triggers fallback route if no other route on the same DOM level matches if (path === '*') { const activeRoutes = Array.from(this.parentNode.getElementsByTagName('view-route')).filter(_ => _.isActive); if (!activeRoutes.length) return [location.pathname, '*']; // normal routes } else { return matchesRoute(path); } return null; } }); ================================================ FILE: public/blog/articles/2025-06-25-routing/example4/404.html ================================================ ================================================ FILE: public/blog/articles/2025-06-25-routing/example4/index-partial.html ================================================ ================================================ FILE: public/blog/articles/2025-06-25-routing/index.html ================================================ Clean client-side routing A twisty maze of stairways.

Clean Client-side Routing

The main Plain Vanilla tutorial explains two ways of doing client-side routing. Both use old school anchor tags for route navigation. First is the traditional multi-page approach described on the Sites page as one HTML file per route, great for content sites, not so great for web applications. Second is the hash-based routing approach decribed on the Applications page, one custom element per route, better for web applications, but not for having clean URLs or having Google index your content.

In this article I will describe a third way, single-file and single-page but with clean URLs using the pushState API, and still using anchor tags for route navigation. The conceit of this technique will be that it needs more code, and the tiniest bit of server cooperation.

Intercepting anchor clicks

To get a true single-page experience the first thing we have to do is intercept link tag navigation and redirect them to in-page events. Our SPA can then respond to these events by updating its routes.

In an example HTML page we can leverage this to implement routing in a <demo-app></demo-app> element.

open example 1 in a separate tab

The first thing we're doing in view-route.js is the interceptNavigation() function. It adds an event handler at the top of the DOM that traps bubbling link clicks and turns them into a navigate event instead of the default action of browser page navigation. Then it also adds a navigate event listener that will update the browser's URL by calling pushState.

In app.js we can listen to the same navigate event to actually update the routes. Suddenly we've implemented a very basic in-page routing, but there are still a bunch of missing pieces.

There and back again

For one, browser back and forward buttons don't actually work. We can click and see the URL update in the browser, but the page does not respond. In order to do this, we need to start listening to popstate events.

However, this risks creating diverging code paths for route navigation, one for the navigate event and one for the popstate event. Ideally a single event listener responds to both types of navigation. A simplistic way of providing a single event to listen can look like this:

Now our views can respond to popstate events and update based on the current route. A second question then becomes: what is the current route? The popstate event does not carry that info. The window.location value does have that, and it is always updated as we navigate, but because it has the full URL it is cumbersome to parse. What is needed is a way of easily parsing it, something like this:

The matchesRoute() function accepts a regex to match as the route, and will wrap it so it is interpreted relative to the current document's URL, making all routes relative to our single page. Now we can clean up the application code leveraging these new generic routing features:

open example 2 in a separate tab

Opening that in a separate tab we can see that the absolute URL neatly updates with the routes, that browser back/forwards navigation updates the view, and that inside the view the route is relative to the document.

Because matchesRoute() accepts a regex, it can be used to capture route components that are used inside of the view. Something like matchesRoute('/details/(?<id>[\\w]+)') would put the ID in matches.groups.id. It's simple, but it gets the job done.

Can you use it in a sentence?

While this rudimentary way of detecting routes works, adding more routes quickly becomes unwieldy. It would be nice to instead have a declarative way of wrapping parts of views inside routes. Enter: a custom element to wrap each route in the page's markup.

Now we can rewrite our app to be a lot more declarative, while preserving the behavior.

open example 3 in a separate tab

404 not found

While things now look like they work perfectly, the illusion is shattered upon reloading the page when it is on the details route. To get rid of the 404 error we need a handler that will redirect to the main index page. This is typically something that requires server-side logic, locking us out from simple static hosting like GitHub Pages, but thanks to the kindness of internet strangers, there is a solution.

It involves creating a 404.html file that GitHub will load for any 404 error (the tiny bit of server cooperation). In this file the route is encoded as a query parameter, the page redirects to index.html, and inside that index page the route is restored.

Adding this last piece to what we already had gets us a complete routing solution for vanilla single page applications that are hosted on GitHub Pages. Here's a live example hosted from there:

To full code of view-route.js and of this example is on GitHub.

================================================ FILE: public/blog/articles/2025-07-13-history-architecture/index.html ================================================ The history of web application architecture a colorful office building viewed at an angle, with the sky behind

The history of web application architecture

I'm old enough to remember what it was like. I first got online in 1994, but by then I was using computers for a few years already. That means I was there for the whole ride, the entire history of web application architecture, from the before times to the present day. So this post will be an overview of the various ways to make a web app, past and present, and their relative trade-offs as I experienced them.

Just to set expectations right: this will cover architectures targeted primarily at proper applications, of the sort that work with user data, and where most screens have to be made unique based on that user data. Web sites where the different visitors are presented with identical pages are a different beast. Although many of these architectures overlap with that use case, it will not be the focus here.

Also, while I will be mentioning some frameworks, they are equally not the focus. Where you see a framework, you can slot in vanilla web development with a bespoke solution.

This will be a long one, so grab a snack, take some time, and let's get going.

Before time began

Back when people first went online, the ruling architecture was offline-first. It looked sort of like this:

architecture diagram of user sending a local file via e-mail

Our user would have a proper desktop application, written in C or C++ most likely, and that would work with local files on their local file system, all perfectly functional when offline. When they wanted to "go online" and share their work, they would dial in to the internet, start their e-mail client and patiently send an e-mail to their friend or colleague containing a copy of their file as an attachment.

This architecture was very simple, and it worked well in the absence of a reliable and fast internet connection. This was a good thing because at the time most people never had a reliable and fast internet connection. There were problems though. For one, the bootstrapping problem: how do you get everyone to have the application installed so they can open and edit the file they received via e-mail? Also, the syncing problem: how are changes kept in sync between multiple devices and users? Merging edits from different files that could have weeks of incompatible edits was always somewhere between frustrating and impossible.

Traditional server-side rendering

Web applications promised they would solve both problems. At first we built them like this:

architecture diagram of a traditional server-side rendered web application

The first thing we did was move all of the stuff from all of the users into a central database somewhere online. Because this database was shared between the users, it became a lot more easy to keep their changes in sync. If one user made an edit to something, everyone else would see it on the next refresh.

To allow users to actually get at this database we had to give them an application to do it. This new-fangled thing called a web application running on a web application server would take http requests coming from the user's browser, SQL query the database for the right set of stuff, and send back an entire web page. Every click, every submit, it would generate a whole new page.

Deployment of those early web applications was often suspiciously simple: someone would connect via FTP to a server, and copy over the files from their local machine. There often weren't even any build steps. There was no step 3.

On the one hand this architecture was convenient. It solved the bootstrapping problem by only demanding that each user have a web browser and an internet connection. It also moved all of the application logic over to the server, keeping it neatly in one place. Crucially it kept the browser's task minimal, important in an era where browsers were much less capable and PC's were orders of magnitude slower than today. If done well, it could even be used to make web applications that still worked with JavaScript disabled. My first mobile web app was like that, sneakily using HTML forms with multiple submit actions and hidden input fields to present interactive navigation through a CRUD interface.

On the other hand, it had many problems, especially early on. HTML wasn't very good, CSS was in its infancy, and early JavaScript was mostly useless. It was hard going building anything at all on the early web. On top of that, web developers were a new breed, and they had to relearn many of the architecture lessons their desktop developer colleagues had already learned through bitter experience. For example, everyone has heard of the adage "If you don't choose a framework, you'll end up building a worse one yourself." That is because for the first few years building your own terrible framework as you went was the norm, until everyone wisened up and started preaching this wisdom. For sure, my own first experiments in web application development in PHP 3 and 4 were all without the benefit of a proper framework.

Web developers also had to learn lessons that their desktop counterparts never had to contend with. Moving the application to the server was convenient, but it exposed it to hackers from all across the world, and the early web application landscape was riddled with embarrassing hacks. Because the threat level on the internet keeps rising this remains a major headache to this day.

Another novel problem was having to care a whole lot about connectivity and server uptime. Because users literally couldn't do anything at all if they didn't have a connection to a working web server, making sure that connection was always there became a pervasive headache. Going to a site and seeing an error 500 message was unsurprisingly common in those early years.

The biggest problems however were throughput, bandwidth and latency. Because almost every click had to reload the whole page, doing anything at all in those early web applications was slow, like really, really slow. At the time, servers were slow to render the page, networks slow to transport it, and PC's and browsers slow to render. That couldn't stand, so something had to change. It was at this point that we saw a fork in the road, and the web developer community split up into two schools of thought that each went their own way. Although, as you will see, they are drawing closer again.

Modern server-side rendering

One branch of the web development tree doubled down on server-side rendering, building further on top of the existing server-side frameworks.

architecture diagram of a server-side rendered application with liveview templates

They tackled the problems imposed by throughput and latency by moving over to a model of partial page updates, where small bits of user-activated JavaScript (originally mostly built with jQuery) would update parts of the page with HTML partials that they fetched from the server.

The evolution of this architecture are so called LiveViews. This is a design first popularized by the Phoenix framework for the obscure Elixir programming language, but quickly adopted in many places.

It uses framework logic to automatically wire up server-side generated templates with bits of JavaScript that will automatically call the server to fetch partial page updates when necessary. The developer has the convenience of not thinking about client-side scripting, while the users get an interactive user experience similar to a JavaScript-rich frontend. Typically the client keeps an open websocket connection to the server, so that server-side changes are quickly and automatically streamed into the page as soon as they occur.

This architecture is conceptually simple to work with as all the logic remains on the server. It also doesn't ask much from the browser, good for slow devices and bandwidth-constrained environments. As a consequence it finds a sweet spot in mostly static content-driven web sites.

But nothing is without trade-offs. This design hits the server on almost every interaction and has no path to offline functionality. Network latency and reliability are its UX killer, and especially on mobile phones – the main way people interact with web apps these days – those can still be a challenge. While this can be mitigated somewhat through browser caching, the limitation is always there. After all, the more that page content is dictated by realtime user input, the more necessary it becomes to push logic to the client. In cases where the network is good however, it can seem like magic even for highly interactive applications, and for that reason it has its diehard fans.

Client-side rendering

There was another branch of web development practice, let's say the ones who were fonder of clever architecture, who had a crazy thought: what if we moved rendering data to HTML from the server to the browser? They built new frameworks, in JavaScript, designed to run in the browser so that the application could be shipped as a whole as part of the initial page load, and every navigation would only need to fetch data for the new route. In theory this allowed for smaller and less frequent roundtrips to the server, and therefore an improvement to the user experience. Thanks to a blooming cottage industry of industry insiders advertising its benefits, it became the dominant architecture for new web applications, with React as the framework of choice to run inside the browser.

This method taken to its modern best practice extreme looks like this:

Architecture diagram of a client-side rendered single-page application

It starts out by moving the application, its framework, and its other dependencies over to the browser. When the page is loaded the entire bundle gets loaded, and then pages can be rendered as routes inside of the single-page application. Every time a new route needs to be rendered the data gets fetched from the server, not the HTML.

The web application server's job is now just providing the application bundle as a single HTML page, and providing API endpoints for loading JSON data for every route. Because those API endpoints naturally end up mirroring the frontend's routes this part usually gets called the backend-for-frontend. This job was so different from the old web server's role that a new generation of frameworks sprung up to be a better fit. Express on node became a very popular choice, as it allowed a great deal of similarity between browser and server codebases, although in practice there's usually not much actual code in common.

For security reasons – after all, the web application servers are on the increasingly dangerous public internet – the best practice became to host backends-for-frontend in a DMZ, a demilitarized zone where the assumption has to be that security is temporary and hostile interlopers could arrive at any time. In addition, if an organization has multiple frontends (and if they have a mobile app they probably have at least two), then this DMZ will contain multiple backends for frontend.

Because there is only a single database to share between those different BFFs, and because of the security risks of connecting to the database from the dangerous DMZ, a best practice became to keep the backend-for-frontend focused on just the part of serving the frontend, and to wrap the database in a separate thing. This separate microservice is an application whose sole job is publishing an API that gatekeeps access to the database. This API is usually in a separate network segment, shielded by firewalls or API gateways, and it is often built in yet another framework better tailored for building APIs, or even in a different programming language like Go or C#.

Of course, having only one microservice is kind of a lonely affair, so even organizations of moderate size would often end up having their backends-for-frontend each talking to multiple microservices.

That's just too many servers to manage, too many network connections to configure, too many builds to run, so people by and large stopped managing their own servers, either for running builds or for runtime hosting. Instead they moved to the cloud, where someone else manages the server, and hosted their backends as docker containers or serverless functions deployed by git-powered CI/CD pipelines. This made some people fabulously wealthy. After all, 74% of Amazon's profit is made from AWS, and over a third of Microsoft's from Azure. It is no accident that there is a persistent drumbeat that everyone should move everything to the cloud. Those margins aren't going to pad themselves.

Incidentally, microservices as database intermediary are also a thing in the world of server-side rendered applications, but in my personal observation those teams seem to choose this strategy less often. Equally incidentally, the word serverless in the context of serverless functions was and is highly amusing to me, since it requires just as many servers, if not more. (I know why it's called that way, that doesn't make it any less funny.)

On paper this client-side rendered architecture has many positive qualities. It is highly modular, which makes the work easy to split up across developers or teams. It pushes page rendering logic into the browser, creating the potential to have a low latency and high quality user experience. The layered nature of the backend and limited scope of the internet-facing backend-for-frontend forms a solid defensive moat against cyberattacks. And the cloud-hosted infrastructure is low effort to manage and easy to scale. A design like this is every architecture astronomer's dream, and I was for a while very enamored with it myself.

In practice though, it just doesn't work very well. It's just too complicated. For larger experienced teams in large organizations it can kind of sort of make sense, and it is no surprise that big tech is a heavy proponent of this architecture. But step away from web-scale for just a second and there's too many parts to build and deploy and keep track of, too many technologies to learn, too many hops a data request has to travel through.

The application's logic gets smeared out across three or more independent codebases, and a lot of overhead is created in keeping all of those in sync. Adding a single data field to a type can suddenly become a whole project. For one application I was working on I once counted in how many places a particular type was explicitly defined, and the tally reached lucky number 7. It is no accident that right around the time that this architecture peaked the use of monorepo tools to bundle multiple projects into a single repository peaked as well.

Go talk to some people just starting out with web development and see how lost they get in trying to figure out all of this stuff, learning all the technologies comprising the Rube Goldberg machine that produces a webpage at the end. See just how little time they have left to dedicate to learning vanilla HTML, CSS and JS, arguably the key things a beginner should be focusing on.

Moreover, the promise that moving the application entirely to the browser would improve the user experience mostly did not pan out. As applications built with client-side frameworks like React or Angular grew, the bundle to be shipped in a page load ballooned to megabytes in size. The slowest quintile of devices and network connections struggled mightily with these heavy JavaScript payloads. It was hoped that Moore's law would solve this problem, but the dynamics of how (mobile) device and internet provider markets work mean that it hasn't been, and that it won't be any time soon. It's not impossible to build a great user experience with this architecture, but you're starting from behind. Well, at least for public-facing web applications.

Client-side rendering with server offload

The designers of client-side frameworks were not wholly insensitive to the frustrations of developers trying to make client-side rendered single-page applications work well on devices and connections that weren't up to the job. They started to offload more and more of the rendering work back to the server. In situations where the content of a page is fixed, static site generation can execute the client-side framework at build time to pre-render pages to HTML. And for situations where content has a dynamic character, server-side rendering was reintroduced back into the mix to offload some of the rendering work back to the server.

The current evolution of these trends is the streaming single-page application:

architecture diagram of a streaming single-page application

In this architecture the framework runs the show in both backend-for-frontend and in the browser. It decides where the rendering happens, and only pushes the work to the browser that must run there. When possible the page is shipped prerendered to the browser and the code for the prerendered parts is not needed in the client bundle. Because some parts of the page are more dynamic than others, they can be rendered on-demand in the server and streamed to the browser where they are slotted into the prerendered page. The bundle that is shipped to the browser can be kept light-weight because it mostly just needs to respond to user input by streaming the necessary page updates from the server over an open websocket connection.

If that sounds suspiciously like the architecture for modern server-side rendering that I described before, that is because it basically is. While a Next.JS codebase is likely to have some client-rendered components still, the extreme of a best practice Astro codebase would see every last component rendered on the server. In doing that they arrive at something functionally no different from LiveView architecture, and with a similar set of trade-offs. These architectures are simpler to work with, but they perform poorly for dynamic applications on low reliability or high latency connections, and they cannot work offline.

Another major simplication of the architecture is getting rid of the database middleman. Microservices and serverless functions are not as hyped as they were, people are happy to build so-called monoliths again, and frameworks are happy to recommend they do so. The meta-frameworks now suggest that the API can be merged into the web application frontend, and the framework will know that those parts are only meant to be run on the server. This radically simplifies the codebase, we're back to a single codebase for the entire application managed by a single framework.

However, TANSTAAFL. This simplification comes at the expense of other things. The Next.JS documentation may claim "Since Server Components are rendered on the server, you can safely make database queries using an ORM or database client." but that doesn't mean that it's actually safe to allow the part that faces the internet to have a direct line to the database. Defense in depth was a good idea, and we're back to trading security for simplicity. There were other reasons that monoliths once fell out of favor. It's like we're now forgetting lessons that were already learned.

Where does that leave us?

So, which architecture should you pick? I wish I could tell you, but you should have understood by now that the answer was always going to be it depends. Riffing on the work of Tolstoy: all web architectures are alike in that they are unhappy in their own unique way.

In a sense, all of these architectures are also unhappy in the same way: there's a whole internet in between the user and their data. There's a golden rule in software architecture: you can't beat physics with code. We draw the internet on diagrams as a cute little cloud, pretending it is not a physical thing. But the internet is wires, and antennas, and satellites, and data centers, and all kinds of physical things and places. Sending a signal through all those physical things and places will always be somewhat unreliable and somewhat slow. We cannot reliably deliver on the promise of a great user experience as long as we put a cute little cloud in between the user and their stuff.

In the next article I'll be exploring an obscure but very different architecture, a crazy thought similar to that of client-side rendering: what happens when we move the user's data from the server back into the client? What is local-first web application architecture?

================================================ FILE: public/blog/articles/2025-07-16-local-first-architecture/example1.html ================================================ Latency simulator

================================================ FILE: public/blog/articles/2025-07-16-local-first-architecture/index.html ================================================ Local-first web application architecture a low view of a beach, grains of sand sharply in focus, a pier blurry in the distance

Local-first web application architecture

Previously in the history of web application architecture I covered the different ways that people have tried to build web applications, and how all of them came with their own unique set of drawbacks. Also mentioned was how there is one drawback that they all share: there is a whole internet between a user and their data, and this makes it hard to deliver a top notch user experience.

Tail latency matters

On the surface, it doesn't seem like this should be the case. Networks are fast, right? But the truth is, they're only fast for some of the people some of the time.

Look at the page load numbers (LCP) of this website for the last week (gathered anonymously):

  • P50: 650 ms
  • P75: 1200 ms
  • P90: 2148 ms
  • P99: 10,636 ms

While half of the visits see page load times well below a second, many see times that are much, much higher. Part of this is due to geography. Going through Azure's P50 roundtrip latency times we can see that some remote connections, like France to Australia, are in the 250 ms range, data center to data center. Azure doesn't disclose P99 latency, but one may assume it is a multiple of the P50 latency.

But our user isn't sitting in a data center, they're probably on a mobile phone, connecting through a slow network. Looking at mozilla's minimum latency numbers we can see that it's not unusual to see another 100 ms of roundtrip latency added by the mobile network, and in reality owing to packet loss and TCP retries it can be a lot more. My own experience taking the train into the office, and trying to get some work done, is that the web can slow to a crawl as I pass through dead zones. This is in a densely populated area in a rich country on the most expensive cell service provider. Most people do not have these same luxuries.

So it's not unusual to see latencies climb far above the threshold of 100 ms, commonly regarded as the threshold for an immediate response. For an unlucky minority of users latencies can even climb above half a second. This matters because if we have to hit the server to do something with the user's data on every click, we will have noticeable and frustrating delay in the interaction. To get a feel of this delay, here's a simulator where you can try out roundtrip latencies from 50 ms to one second:

Can you tell how much nicer 100 ms and below feel? How the buttons actually feel lighter to press? By contrast, 500 ms and above of roundtrip latency are just a slog, painful to use. The buttons are heavy and cumbersome. This is human psychology at work, and these lessons were learned in the desktop era but forgotten and never quite relearned for the web. If we put over 100 ms of latency in between a user's click and the resulting effect they will feel some degree of frustration, and we know that the internet cannot deliver below 100 ms of roundtrip latency except in the luckiest of cases.

Solutions

We can use some kind of closer-to-the-user cache, in the form of a CDN or a browser cache or a service worker. This allows the content that needs to be loaded based on the user's click to be fetched from something that has a better shot at being below that magic 100 ms of latency. But this only works if what we need to load can be cached, and if we can afford to cache it. For a web application that works with user data, this is typically not the case.

We can host the application in a georedundant way, have application servers across the world and use a georedundant database like Google Spanner or Azure Cosmos DB. This quickly gets complicated and expensive, and can only be achieved through a great amount of vendor lock-in. Crucially, it probably does not get us past the 100 ms barrier anyway.

We can render client-side, using JavaScript to create and update the HTML page, so that an update on the screen can happen immediately after the user's click. But this only works if what the user is doing is not updating a piece of server-side data. Otherwise we have to show some kind of loading or saving indicator until the server responds, and then we're back to roundtrip latency.

Bottom line, and once again: the basic problem is that the internet is in between the user and their data. So what if we moved the data to the user?

Local-first

This concept has been around for a while and it is known as a local-first application. To apply it to the web, let's start from the basic design of a client-side rendered web application as covered in the previous article:

architecture diagram of a client-side rendered web application

This design does not need the server for rendering, so it has the theoretical potential to hit 100 ms. But because the user's interactions have to fetch data from the remote database and update it as well – a database sitting multiple network hops away – in practice we rarely actually hit that low latency. We can move the data to the user by doing something like this:

architecture diagram of a local-first application with server-side sync

The user's data gets duplicated into a local IndexedDB. More than duplicated actually as this becomes the only copy of the data that the user will interact with directly. The backend-for-frontend and API that used to gatekeep access to this data similarly get moved into the client, as part of a service worker. This worker code can be very similar to what the server-side version would be, it can even reuse express (although it probably shouldn't). Because the service worker's API for all intents and purposes looks like a server-side API to the frontend codebase, that frontend can be built in all the usual ways, except now with the guarantee that every server roundtrip completes in milliseconds.

To avoid slow page loads each time the web application is opened it also needs to be cached locally as part of the service worker. By packaging this as a progressive web app installs become possible onto the user's home screen, giving an experience not that unlike installing a native mobile app. The application server's job is now reduced to only providing that initial application install, and it can become a simple (and free) static web host.

Here comes trouble

Both the application and the data are now running locally, meaning the user doesn't need the network at all to get things done. This isn't just local-first, it is offline-first. But like all things in software architecture, we are trading one set of problems for another.

There still needs to be a way to get data onto other devices, or to other users, or simply backed up into the cloud. That means the service worker also gets the job of (asynchronously) uploading changes to a server API which will store it in a database, as well as pulling server-side changes back down. The server-side version acts as the master copy, and the server-side API gets the thankless job of merging client-side changes with it.

Not so fast though. Our user might be editing offline for a considerably long time, and the data on the server may have been changed in the meanwhile from another device or by another user. The job of merging those changes can be ... complicated. A good strategy is needed for merging. A possible path is to use CRDT algorithms from a library like automerge, as suggested by the local-first article linked above. However, for many cases a simpler bespoke algorithm that is aware of the specific data types being merged probably works well enough, as I discovered when making an offline-capable work orders web application that used this strategy.

As the local-first application is now directly talking to this API, it needs a way to authenticate. This can be a reason to still have a minimal backend-for-frontend on the server. An alternative is to use a separate OpenID Connect identity provider with a PKCE authorization flow to obtain an access token. PKCE is an authorization flow developed for use by mobile apps but also usable by web apps that enables secretless API authentication.

Another major caveat is that the entire codebase needs to be on the client, which necessitates keeping it under a tight performance and size budget. Careful curation of dependencies is key, and a javascript footprint that runs in the megabytes is verboten. Vanilla web development can be a solution here, with its emphasis on using the platform to its limits without bringing in dependencies. A lightweight framework like Lit or Preact will do as well.

This isn't just a theoretical exercise. The team at Superhuman built their better gmail than gmail more or less as described here, using React as the framework, and the thing that sets their web app apart is its blistering speed. You can go read how they did it on their blog (part 1, part 2) or listen to the retelling on the Syntax podcast.

Loco-first

But we can push it even further, in theory at least. In the previous iteration of the architecture we still have the application-specific database and syncing logic on the server, but what if we instead did away with every last piece of server-side application logic?

architecture diagram of a local-first application without server-side logic

The syncing engine now gets moved in the client, and what it uploads to the server are just files. That means all we need is a generic cloud drive, like Dropbox or Onedrive. These cloud drive services often support cross-origin API access with OpenID Connect authentication using PKCE. That means the service worker in the client doesn't need any application servers to upload its data to the cloud storage.

The onboarding experience becomes a choice screen where the user picks their cloud drive option. The application will redirect the user to the Dropbox or Onedrive or <insert vendor here> login page, and obtains an access token using PKCE authorization flow that is persisted locally. This token is used to connect to the user's cloud drive from inside the service worker. The application will bootstrap itself from whatever files are already in the drive, and will regularly synchronize with the current contents of the drive using its service worker logic.

Instead of a cloud drive, another option is the use of the File System Access API to get a handle to a local folder. The user can set up this folder as cloud-synced, or they can back it up and copy it to other devices using a method of their choice. This solution allows the app to have complete privacy, as the user's data never leaves their device or network. The caveat is incomplete browser support. Come on Safari and Firefox, do your bit, for privacy!

In a single user scenario, each of their devices uploads its own copy of the data, either as a history of actions or as the latest state. When a device's service worker wants to sync, it checks all of the files of the other devices to see if they have been modified. If they are, it downloads updates and merges them into the local IndexedDB. This is the same work as the previous architecture iteration did in its server-side syncing API, only now in reverse: instead of every device going to the server and presenting its copy to merge, it is the service worker going to each device's copy and checking if it needs merging.

There is no longer a master copy of the data, only one copy per device, all on equal footing, with an eventual consistency arising out of the devices merging from each other's copies. This is the same philosophy as CRDTs, or of the Operational Transformation algorithm that underpins Google Docs. No matter though, when you get deep enough into consistency models (I recommend Aphyr's writings or Martin Kleppmann's book), you'll realize that there is never such as a thing as a reliable master copy in a distributed system, even in cases where there is a central database.

A multi-user scenario can be realized by our user subscribing to someone else's shared cloud drive folder, where the other user shares their copy of the data as read-only. When Alice shares her folder with Bob, Bob shares his with Alice, and they both subscribe to each other's updates, then they can work on a shared document without ever having given each other direct write access.

As this application has no servers outside of the lightly loaded static web host, which in 2025 is free to host even at scale, the hosting cost drops to zero almost regardless of the number of users. The cloud drive costs are carried by each user individually, only responsible for their own share of storage. The only hosting cost to the application's owner would be the domain name. The climate footprint is minimal.

By eliminating the central database and its adjoining server-side logic it also eliminates the main target for hackers. Because compromises can only occur one user at a time they lack a sufficient reward for hacking the application. On top of that, the only servers are the static web host and the major cloud drive vendors, both practically impossible to hack. This web app would be highly secure and remain secure with barely any maintenance.

This architecture would be a cloud vendor's worst nightmare if it ever became popular, as they cannot earn a dime from it (aside from the basic cloud drive pennies). Thankfully for them, it is impossible to monetize for the company that builds such a web app as well. No server == no revenue opportunity. For now this is just a crazy theoretical exercise, far removed from where the leading frontend architecture voices are driving us, but the possibility fascinates me.

================================================ FILE: public/blog/articles/2026-03-01-redesigning-plain-vanilla/index.html ================================================ Redesigning Plain Vanilla the index page of an old nuclear disaster prevention manual, designed in Swiss style

Redesigning Plain Vanilla

Design has never been the part of building for the web that I felt comfortable with. I would hide behind the shorthand "I'm not a designer" and just use someone else's design, or throw something together that I wasn't happy with. The original design of Plain Vanilla was shipped very much in that not-happy-with-it mindset.

So when I decided to finally take a web design course, settling on DesignAcademy.io and had to pick a project to apply the course's learnings to, turning this site's design into something I liked was top of mind. This article documents how and why the new design came about.

The course starts out by making you really think about the content that you want to apply a design to. They challenge you to make your web page work as a google doc, so I did precisely that. In revisiting the landing page and thinking through its text, about half of the characters were cut, without losing any of the meaning.

Lesson 1: less is more.

The next challenge the course throws at you is to decide on a style and to gather inspiration for your design. What I wanted the design of Plain Vanilla to communicate was a sort of timelessness, like a manual from an earlier time, that at the same time feels simple and fresh. In looking for inspiration I settled on Swiss style, the 1950's minimalist style that manages to feel modern and old at the same time. I absolutely adore the old manuals from the 50's, as in the image at the top of this page, where minimalist design relying heavily on typography and spacing brings the content to the foreground.

With this style and many googled-together examples of it as inspiration in hand, I took on the next challenge: make a first sketch of the design by simply copy pasting together elements of web sites and imagery that inspired you into a rough first outline. The second half of the challenge: take that design and layer your content over it. The result of this exercise was the initial redesign. Notice how the left half is literally copy pasted together bits of screenshots.

initial redesign sketch

Lesson 2: taking ideas from everywhere is a quick way to get started.

The rest of the course was a series of design exercises on top of that initial draft, to choose fonts, colors, imagery, to apply a grid system and figure out spacing, and to sort out the finer aspects. Some elements of the initial redesign survived this process, others didn't, and what I ended up with in the final Affinity Designer file is pretty close to the shipping redesign.

final redesign sketch

Lesson 3: design doesn't have to be intimidating if you have the right process.

The actual work of translating of the design to code wasn't all that complicated. There was already a good baseline of semantic markup, so the redesign ended up mostly constrained to central CSS changes. Most of the time was taken up by responsiveness, something absent from the design mockup. Many of the final decisions on the exact behavior were made to favor code simplicity. This is for example why I decided not to add animations.

This is also why I chose the popover API as the strategy for having a responsive hamburger menu on small screens. While the popover API presently only has 87% support on caniuse.com, I felt that for the audience of this site support should be sufficient, and the dramatic code simplification it allowed was undeniable. The nav menu works without any JavaScript. It presents as a toggled menu by default, and uses CSS to become permanently visible on larger screens.

This was also a good opportunity to revisit the website's content. As it turns out, not a lot needed to change. Web standards don't actually change that often. I did update some parts where evolving baseline support took away caveats (for example, CSS nesting is now safe to use), and added some links to newly baseline features like popover, dialog and mutually-exclusive details.

Lesson 4: when you build on top of web standards, you don't need to do a lot of maintenance.

In the end, I'm finally happy with the design of Plain Vanilla. I still haven't gotten around to adding a dark mode, but it's on the todo list. There may be some hiccups with the new design, so if you see any please let me know by making an issue on GitHub. Do you like the new design, do you hate it? Please let me know.

================================================ FILE: public/blog/articles/2026-03-01-redesigning-plain-vanilla/nav-menu.html ================================================ ================================================ FILE: public/blog/articles/2026-03-09-details-matters/example1/index-partial.html ================================================
A summary Some details to further explain the summary.
================================================ FILE: public/blog/articles/2026-03-09-details-matters/example1/index.html ================================================ Example 1
A summary Some details to further explain the summary.
================================================ FILE: public/blog/articles/2026-03-09-details-matters/example2/index-partial.html ================================================
A summary Some details to further explain the summary.
================================================ FILE: public/blog/articles/2026-03-09-details-matters/example2/index.html ================================================ Example 2
A summary Some details to further explain the summary.
================================================ FILE: public/blog/articles/2026-03-09-details-matters/example3/index-partial.html ================================================
A summary Some details to further explain the summary.
================================================ FILE: public/blog/articles/2026-03-09-details-matters/example3/index.html ================================================ Example 3
A summary Some details to further explain the summary.
================================================ FILE: public/blog/articles/2026-03-09-details-matters/example4/index-partial.html ================================================
A summary Some details to further explain the summary. And this is another sentence that spends a great deal of time saying nothing.
================================================ FILE: public/blog/articles/2026-03-09-details-matters/example4/index.html ================================================ Example 4
A summary Some details to further explain the summary. And this is another sentence that spends a great deal of time saying nothing.
================================================ FILE: public/blog/articles/2026-03-09-details-matters/example5/index-partial.html ================================================
...
...
...
================================================ FILE: public/blog/articles/2026-03-09-details-matters/example5/index.html ================================================ Example 4
Item 1 This is the first item.
Item 2 Lorem ipsum dolor sit amet, consectetur adipiscing elit. Suspendisse malesuada lacus ex, sit amet blandit leo lobortis eget.
Item 3 Aha, and yet another item.
================================================ FILE: public/blog/articles/2026-03-09-details-matters/index.html ================================================ <details> matters detail of a London street crossing showing the words look right

<details> matters

The richness of HTML continues to be a delightful surprise.

For example, take an unassuming element like <details>. We've all seen it before. Here it is in its pure form:

We can merrily click the summary to open and close the details to our heart's content. But this is kind of plain-looking. Can we change that triangle into something else?

In a just world, this would work by neatly styling the ::marker pseudo-element, but like so often Safari is being annoying. Thankfully we can instead replace the marker by removing it with list-style: none; and adding a new summary marker on the ::before or ::after pseudo-elements.

And really, the summary element can contain anything at all, inviting our creativity. Here's a fancier example that replaces the marker by an animated svg icon.

There's no reason to stop at decorating the marker though. We can make the details element look like anything we want, all it takes is the right CSS. If we wanted, we could make it look like a card.

One caveat with this example is the ::details-content pseudo-element used to select the expanded content area. This is baseline 2025 so still pretty new. For older browsers you can wrap the content in a div and style that instead.

But wait, there's more! In baseline 2024 <details> picked up a neat trick where all the elements with the same name attribute are mutually exclusive, meaning only one can be open at the same time. All we have to do is stack them to magically get an accordion.

There's no magic code here to make the accordion work aside from the name attributes. HTML is doing the magic for us, which means this also happens to be fully keyboard-navigable and accessible. The only tricky bit is animating the expanding and collapsing of the cards. In this example that is implemented using interpolate-size: allow-keywords, which is a Chromium-only trick hopefully coming to other browsers near you. Thankfully in those other browsers this is still perfectly functional, just not as smoothly animating.

By the way, the mutually exclusive <details> elements do not even need to share a parent element. These accordion cards could be wrapped in custom elements, or <fieldset> or <form> elements, and they would work just fine in all browsers, today. But you'll have to take my word for it. After all, I have to leave some exercises up to the reader. 😉

Like I said, the richness of HTML continues to be a delightful surprise. If you ask me then <details> has a bright future ahead of it.

No JavaScript was harmed in the making of this blog post.

================================================ FILE: public/blog/articles/index.json ================================================ [ { "slug": "2026-03-09-details-matters", "title": "
matters", "summary": "An oddball element set to take the main stage.", "published": "2026-03-09", "author": "Joeri Sebrechts" }, { "slug": "2026-03-01-redesigning-plain-vanilla", "title": "Redesigning Plain Vanilla", "summary": "Stepping out of my comfort zone.", "published": "2026-03-01", "author": "Joeri Sebrechts" }, { "slug": "2025-07-16-local-first-architecture", "title": "Local-first web application architecture", "summary": "Maybe we just need to dig a little deeper (into the client).", "published": "2025-07-16", "author": "Joeri Sebrechts" }, { "slug": "2025-07-13-history-architecture", "title": "The history of web application architecture", "summary": "The many different ways of building for the web, and their many frustrations.", "published": "2025-07-13", "author": "Joeri Sebrechts" }, { "slug": "2025-06-25-routing", "title": "Clean client-side routing", "summary": "Finding a nice way of doing single-page app routing without a library.", "published": "2025-06-25", "author": "Joeri Sebrechts" }, { "slug": "2025-06-12-view-transitions", "title": "Bringing React's to vanilla JS", "summary": "Bringing React's declarative view transitions API to vanilla as a custom element.", "published": "2025-06-12", "author": "Joeri Sebrechts" }, { "slug": "2025-05-09-form-control", "title": "Making a new form control", "summary": "Building a form control as a custom element.", "published": "2025-05-09", "author": "Joeri Sebrechts" }, { "slug": "2025-04-21-attribute-property-duality", "title": "The attribute/property duality", "summary": "How to work with attributes and properties in custom elements.", "published": "2025-04-21", "author": "Joeri Sebrechts" }, { "slug": "2025-01-01-new-years-resolve", "title": "New year's resolve", "summary": "import.meta.resolve and other ways to avoid bundling", "published": "2025-01-01", "author": "Joeri Sebrechts" }, { "slug": "2024-12-16-caching-vanilla-sites", "title": "Caching vanilla sites", "summary": "Strategies for cache invalidation on vanilla web sites.", "published": "2024-12-16", "author": "Joeri Sebrechts" }, { "slug": "2024-10-20-editing-plain-vanilla", "title": "Editing Plain Vanilla", "summary": "How to set up VS Code for a vanilla web project.", "published": "2024-10-20", "author": "Joeri Sebrechts" }, { "slug": "2024-10-07-needs-more-context", "title": "Needs more context", "summary": "A better way to do context for web components.", "published": "2024-10-07", "author": "Joeri Sebrechts" }, { "slug": "2024-09-30-lived-experience", "title": "Lived experience", "summary": "Thoughts on the past and future of frameworks, web components and web development.", "published": "2024-09-30", "author": "Joeri Sebrechts" }, { "slug": "2024-09-28-unreasonable-effectiveness-of-vanilla-js", "title": "The unreasonable effectiveness of vanilla JS", "summary": "A case study in porting intricate React code to vanilla.", "published": "2024-09-28", "author": "Joeri Sebrechts" }, { "slug": "2024-09-16-life-and-times-of-a-custom-element", "title": "The life and times of a web component", "summary": "The entire lifecycle of a web component, from original creation to when a shadow crosses.", "published": "2024-09-16", "author": "Joeri Sebrechts" }, { "slug": "2024-09-09-sweet-suspense", "title": "Sweet Suspense", "summary": "React-style lazy loading of web components.", "published": "2024-09-09", "author": "Joeri Sebrechts" }, { "slug": "2024-09-06-how-fast-are-web-components", "title": "How fast are web components?", "summary": "Benchmarking the relative performance of different web component techniques.", "published": "2024-09-06", "updated": "2024-09-15", "author": "Joeri Sebrechts" }, { "slug": "2024-09-03-unix-philosophy", "title": "A unix philosophy for web development", "summary": "Maybe all web components need to be a light-weight framework is the right set of helper functions.", "published": "2024-09-03", "author": "Joeri Sebrechts" }, { "slug": "2024-08-30-poor-mans-signals", "title": "Poor man's signals", "summary": "Signals are all the rage over in frameworkland, so let's bring them to vanilla JS.", "published": "2024-08-30", "author": "Joeri Sebrechts" }, { "slug": "2024-08-25-vanilla-entity-encoding", "title": "Vanilla entity encoding", "summary": "The first version of this site didn't use entity encoding in the examples. Now it does.", "published": "2024-08-25", "author": "Joeri Sebrechts" }, { "slug": "2024-08-17-lets-build-a-blog", "title": "Let's build a blog, vanilla-style!", "summary": "Explaining how this vanilla web development blog was built, using nothing but vanilla web techniques.", "published": "2024-08-17", "updated": "2024-08-26", "author": "Joeri Sebrechts" } ] ================================================ FILE: public/blog/components/blog-archive.js ================================================ import { html } from '../../lib/html.js'; class BlogArchive extends HTMLElement { connectedCallback() { this.textContent = 'Loading...'; fetch(import.meta.resolve('../articles/index.json')) .then(response => response.json()) .then(articles => { // sort articles by published descending articles.sort((a, b) => { return -a.published.localeCompare(b.published); }); this.innerHTML = '
    ' + articles.map(item => html`
  • ${item.title}

    ${item.summary}

  • `).join('\n') + '
'; }) .catch(e => { this.textContent = e.message; }); } } export const registerBlogArchive = () => customElements.define('blog-archive', BlogArchive); ================================================ FILE: public/blog/components/blog-footer.js ================================================ import { html } from '../../lib/html.js'; class BlogFooter extends HTMLElement { connectedCallback() { const mastodonUrl = this.getAttribute('mastodon-url'); this.innerHTML = html` `; } } export const registerBlogFooter = () => customElements.define('blog-footer', BlogFooter); ================================================ FILE: public/blog/components/blog-header.js ================================================ import { html } from '../../lib/html.js'; class BlogHeader extends HTMLElement { connectedCallback() { this.role = 'banner'; const title = this.getAttribute('title') || 'Plain Vanilla Blog'; const published = this.getAttribute('published'); const updated = this.getAttribute('updated'); const template = document.createElement('template'); template.innerHTML = html`

${title}

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

`; this.insertBefore(template.content, this.firstChild); } } export const registerBlogHeader = () => customElements.define('blog-header', BlogHeader); ================================================ FILE: public/blog/components/blog-latest-posts.js ================================================ import { html } from '../../lib/html.js'; const pathify = (url) => url && new URL(url).pathname.replace('/blog/', './'); class LatestPosts extends HTMLElement { connectedCallback() { this.textContent = "Loading..."; // show the most recent items from the RSS feed fetch(import.meta.resolve('../feed.xml')) .then(response => response.text()) .then(text => new DOMParser().parseFromString(text, "text/xml")) .then(data => { const parserError = data.querySelector('parsererror div'); if (parserError) { throw new Error(parserError.textContent); } // only the 6 most recent entries const feedItems = [...data.querySelectorAll('entry')].slice(0, 6) .map(item => ({ title: item.querySelector('title')?.textContent, link: pathify(item.querySelector('id')?.textContent), published: item.querySelector('published')?.textContent, updated: item.querySelector('updated')?.textContent, summary: item.querySelector('summary')?.textContent, image: pathify(item.querySelector('content')?.getAttribute('url')) })) // sanity check .filter(item => item.link && item.title); if (feedItems.length) { this.innerHTML = '
    ' + feedItems.map(item => html`
  • ${item.image ? html`` : ''}

    ${item.title}

    ${item.summary}

  • `).join('\n') + '
'; } else { this.innerHTML = 'Something went wrong...'; } }) .catch(e => this.textContent = e.message); } } export const registerBlogLatestPosts = () => customElements.define('blog-latest-posts', LatestPosts); ================================================ FILE: public/blog/example-base.css ================================================ /* base CSS used for examples in blog articles */ @import "../styles/reset.css"; @import "../styles/variables.css"; html { margin: 0 auto; max-width: 800px; background-color: var(--background-color); color: var(--text-color); } body { font-family: var(--font-system); font-weight: normal; margin: 1.5em; } a, a:hover { color: var(--link-color); } ================================================ FILE: public/blog/feed.xml ================================================ Plain Vanilla Blog https://plainvanillaweb.com/blog/ https://plainvanillaweb.com/favicon.ico https://plainvanillaweb.com/android-chrome-512x512.png 2026-03-09T12:00:00.000Z Joeri Sebrechts <![CDATA[<details> matters]]> https://plainvanillaweb.com/blog/articles/2026-03-09-details-matters/ 2026-03-09T12:00:00.000Z 2026-03-09T12:00:00.000Z The richness of HTML continues to be a delightful surprise.

For example, take an unassuming element like <details>. We've all seen it before. Here it is in its pure form:

<details>
    <summary>A summary</summary>
    Some details to further explain the summary.
</details>

undecorated details

We can merrily click the summary to open and close the details to our heart's content. But this is kind of plain-looking. Can we change that triangle into something else?

replacing the marker

In a just world, this would work by neatly styling the ::marker pseudo-element, but like so often Safari is being annoying. Thankfully we can instead replace the marker by removing it with list-style: none; and adding a new summary marker on the ::before or ::after pseudo-elements.

<style>
    details {
        summary {
            list-style: none;
        }
        summary::before {
            content: "\1F512";
            margin: 0 0.3em 0 -0.1em;
        }
        &[open] summary::before {
            content: "\1F513";
        }
    }
</style>
<details>
    <summary>A summary</summary>
    Some details to further explain the summary.
</details>

And really, the summary element can contain anything at all, inviting our creativity. Here's a fancier example that replaces the marker by an animated svg icon.

replacing the marker, but more fancy

<style>
    details {
        summary {
            list-style: none;
            user-select: none;
            svg {
                display: inline-block;
                vertical-align: bottom;
                color: gray;
                rotate: 0;
                transition: rotate 0.3s ease-out;
            }
        }
        &[open] summary {
            svg {
                rotate: 180deg;
            }
        }
    }
</style>
<details>
    <summary>
        A summary
        <svg focusable="false" width="24" height="24" aria-hidden="true">
            <path d="M6.34317 7.75732L4.92896 9.17154L12 16.2426L19.0711 9.17157L17.6569 7.75735L12 13.4142L6.34317 7.75732Z" fill="currentColor" />
        </svg>
    </summary>
    Some details to further explain the summary.
</details>

There's no reason to stop at decorating the marker though. We can make the details element look like anything we want, all it takes is the right CSS. If we wanted, we could make it look like a card.

card-like appearance

<style>
    details {
        border: 1px solid gray;
        max-width: 20em;

        summary {
            position: relative;
            list-style: none;
            padding: 0.2em 0.4em;
            user-select: none;
            svg {
                position: absolute;
                right: 0.2em;
                top: 0.3em;
                color: gray;
                rotate: 0;
                transition: rotate 0.3s ease-out;
            }
        }

        &[open] {
            summary {
                font-weight: bold;
                svg {
                    rotate: 180deg;
                }
            }

            &::details-content {
                border-top: 1px solid gray;
                padding: 0.2em 0.4em;        
            }
        }
    }
</style>
<details>
    <summary>
        A summary
        <svg focusable="false" width="24" height="24" aria-hidden="true">
            <path d="M6.34317 7.75732L4.92896 9.17154L12 16.2426L19.0711 9.17157L17.6569 7.75735L12 13.4142L6.34317 7.75732Z" fill="currentColor" />
        </svg>
    </summary>
    Some details to further explain the summary.
    And this is another sentence that spends a great deal of time saying nothing.
</details>

One caveat with this example is the ::details-content pseudo-element used to select the expanded content area. This is baseline 2025 so still pretty new. For older browsers you can wrap the content in a div and style that instead.

But wait, there's more! In baseline 2024 <details> picked up a neat trick where all the elements with the same name attribute are mutually exclusive, meaning only one can be open at the same time. All we have to do is stack them to magically get an accordion.

card-like appearance

<style>
    /* ... details styles repeated from previous example ... */

    details + details {
        border-top: none;
    }

    @supports (interpolate-size: allow-keywords) {
        :root {
            interpolate-size: allow-keywords;
        }

        details::details-content {
            /* allow discrete transitions for the details content (animating to height auto) */
            transition:
                height 0.3s ease,
                content-visibility 0.3s ease allow-discrete;
            /* hide the details content by default */
            height: 0;
            overflow: clip;
        }

        /* show the details content when the details is open */
        details[open]::details-content {
            height: auto;
        }
    }
</style>
<details name="accordion">
    ...
</details>
<details name="accordion">
    ...
</details>
<details name="accordion">
    ...
</details>

There's no magic code here to make the accordion work aside from the name attributes. HTML is doing the magic for us, which means this also happens to be fully keyboard-navigable and accessible. The only tricky bit is animating the expanding and collapsing of the cards. In this example that is implemented using interpolate-size: allow-keywords, which is a Chromium-only trick hopefully coming to other browsers near you. Thankfully in those other browsers this is still perfectly functional, just not as smoothly animating.

By the way, the mutually exclusive <details> elements do not even need to share a parent element. These accordion cards could be wrapped in custom elements, or <fieldset> or <form> elements, and they would work just fine in all browsers, today. But you'll have to take my word for it. After all, I have to leave some exercises up to the reader. 😉

Like I said, the richness of HTML continues to be a delightful surprise. If you ask me then <details> has a bright future ahead of it.

No JavaScript was harmed in the making of this blog post.

]]>
<![CDATA[Redesigning Plain Vanilla]]> https://plainvanillaweb.com/blog/articles/2026-03-01-redesigning-plain-vanilla/ 2026-03-01T12:00:00.000Z 2026-03-01T12:00:00.000Z Design has never been the part of building for the web that I felt comfortable with. I would hide behind the shorthand "I'm not a designer" and just use someone else's design, or throw something together that I wasn't happy with. The original design of Plain Vanilla was shipped very much in that not-happy-with-it mindset.

So when I decided to finally take a web design course, settling on DesignAcademy.io and had to pick a project to apply the course's learnings to, turning this site's design into something I liked was top of mind. This article documents how and why the new design came about.

The course starts out by making you really think about the content that you want to apply a design to. They challenge you to make your web page work as a google doc, so I did precisely that. In revisiting the landing page and thinking through its text, about half of the characters were cut, without losing any of the meaning.

Lesson 1: less is more.

The next challenge the course throws at you is to decide on a style and to gather inspiration for your design. What I wanted the design of Plain Vanilla to communicate was a sort of timelessness, like a manual from an earlier time, that at the same time feels simple and fresh. In looking for inspiration I settled on Swiss style, the 1950's minimalist style that manages to feel modern and old at the same time. I absolutely adore the old manuals from the 50's, as in the image at the top of this page, where minimalist design relying heavily on typography and spacing brings the content to the foreground.

With this style and many googled-together examples of it as inspiration in hand, I took on the next challenge: make a first sketch of the design by simply copy pasting together elements of web sites and imagery that inspired you into a rough first outline. The second half of the challenge: take that design and layer your content over it. The result of this exercise was the initial redesign. Notice how the left half is literally copy pasted together bits of screenshots.

initial redesign sketch

Lesson 2: taking ideas from everywhere is a quick way to get started.

The rest of the course was a series of design exercises on top of that initial draft, to choose fonts, colors, imagery, to apply a grid system and figure out spacing, and to sort out the finer aspects. Some elements of the initial redesign survived this process, others didn't, and what I ended up with in the final Affinity Designer file is pretty close to the shipping redesign.

final redesign sketch

Lesson 3: design doesn't have to be intimidating if you have the right process.

The actual work of translating of the design to code wasn't all that complicated. There was already a good baseline of semantic markup, so the redesign ended up mostly constrained to central CSS changes. Most of the time was taken up by responsiveness, something absent from the design mockup. Many of the final decisions on the exact behavior were made to favor code simplicity. This is for example why I decided not to add animations.

This is also why I chose the popover API as the strategy for having a responsive hamburger menu on small screens. While the popover API presently only has 87% support on caniuse.com, I felt that for the audience of this site support should be sufficient, and the dramatic code simplification it allowed was undeniable. The nav menu works without any JavaScript. It presents as a toggled menu by default, and uses CSS to become permanently visible on larger screens.

<nav id="menu-nav" popover aria-label="main">
    <ol>
        <li><a href="#" aria-current="page">Welcome</a></li>
        <li><a href="pages/components.html">Components</a></li>
        <li><a href="pages/styling.html">Styling</a></li>
        <li><a href="pages/sites.html">Sites</a></li>
        <li><a href="pages/applications.html">Applications</a></li>
        <li class="nav-right"><a href="blog/">Blog</a></li>
    </ol>
</nav>
<button popovertarget="menu-nav" popovertargetaction="toggle" aria-label="menu">
    &mldr;
</button>

This was also a good opportunity to revisit the website's content. As it turns out, not a lot needed to change. Web standards don't actually change that often. I did update some parts where evolving baseline support took away caveats (for example, CSS nesting is now safe to use), and added some links to newly baseline features like popover, dialog and mutually-exclusive details.

Lesson 4: when you build on top of web standards, you don't need to do a lot of maintenance.

In the end, I'm finally happy with the design of Plain Vanilla. I still haven't gotten around to adding a dark mode, but it's on the todo list. There may be some hiccups with the new design, so if you see any please let me know by making an issue on GitHub. Do you like the new design, do you hate it? Please let me know.

]]>
<![CDATA[Local-first web application architecture]]> https://plainvanillaweb.com/blog/articles/2025-07-16-local-first-architecture/ 2025-07-16T12:00:00.000Z 2025-07-16T12:00:00.000Z Previously in the history of web application architecture I covered the different ways that people have tried to build web applications, and how all of them came with their own unique set of drawbacks. Also mentioned was how there is one drawback that they all share: there is a whole internet between a user and their data, and this makes it hard to deliver a top notch user experience.

Tail latency matters

On the surface, it doesn't seem like this should be the case. Networks are fast, right? But the truth is, they're only fast for some of the people some of the time.

Look at the page load numbers (LCP) of this website for the last week (gathered anonymously):

  • P50: 650 ms
  • P75: 1200 ms
  • P90: 2148 ms
  • P99: 10,636 ms

While half of the visits see page load times well below a second, many see times that are much, much higher. Part of this is due to geography. Going through Azure's P50 roundtrip latency times we can see that some remote connections, like France to Australia, are in the 250 ms range, data center to data center. Azure doesn't disclose P99 latency, but one may assume it is a multiple of the P50 latency.

But our user isn't sitting in a data center, they're probably on a mobile phone, connecting through a slow network. Looking at mozilla's minimum latency numbers we can see that it's not unusual to see another 100 ms of roundtrip latency added by the mobile network, and in reality owing to packet loss and TCP retries it can be a lot more. My own experience taking the train into the office, and trying to get some work done, is that the web can slow to a crawl as I pass through dead zones. This is in a densely populated area in a rich country on the most expensive cell service provider. Most people do not have these same luxuries.

So it's not unusual to see latencies climb far above the threshold of 100 ms, commonly regarded as the threshold for an immediate response. For an unlucky minority of users latencies can even climb above half a second. This matters because if we have to hit the server to do something with the user's data on every click, we will have noticeable and frustrating delay in the interaction. To get a feel of this delay, here's a simulator where you can try out roundtrip latencies from 50 ms to one second:

roundtrip latency simulator

Can you tell how much nicer 100 ms and below feel? How the buttons actually feel lighter to press? By contrast, 500 ms and above of roundtrip latency are just a slog, painful to use. The buttons are heavy and cumbersome. This is human psychology at work, and these lessons were learned in the desktop era but forgotten and never quite relearned for the web. If we put over 100 ms of latency in between a user's click and the resulting effect they will feel some degree of frustration, and we know that the internet cannot deliver below 100 ms of roundtrip latency except in the luckiest of cases.

Solutions

We can use some kind of closer-to-the-user cache, in the form of a CDN or a browser cache or a service worker. This allows the content that needs to be loaded based on the user's click to be fetched from something that has a better shot at being below that magic 100 ms of latency. But this only works if what we need to load can be cached, and if we can afford to cache it. For a web application that works with user data, this is typically not the case.

We can host the application in a georedundant way, have application servers across the world and use a georedundant database like Google Spanner or Azure Cosmos DB. This quickly gets complicated and expensive, and can only be achieved through a great amount of vendor lock-in. Crucially, it probably does not get us past the 100 ms barrier anyway.

We can render client-side, using JavaScript to create and update the HTML page, so that an update on the screen can happen immediately after the user's click. But this only works if what the user is doing is not updating a piece of server-side data. Otherwise we have to show some kind of loading or saving indicator until the server responds, and then we're back to roundtrip latency.

Bottom line, and once again: the basic problem is that the internet is in between the user and their data. So what if we moved the data to the user?

Local-first

This concept has been around for a while and it is known as a local-first application. To apply it to the web, let's start from the basic design of a client-side rendered web application as covered in the previous article:

architecture diagram of a client-side rendered web application

This design does not need the server for rendering, so it has the theoretical potential to hit 100 ms. But because the user's interactions have to fetch data from the remote database and update it as well – a database sitting multiple network hops away – in practice we rarely actually hit that low latency. We can move the data to the user by doing something like this:

architecture diagram of a local-first application with server-side sync

The user's data gets duplicated into a local IndexedDB. More than duplicated actually as this becomes the only copy of the data that the user will interact with directly. The backend-for-frontend and API that used to gatekeep access to this data similarly get moved into the client, as part of a service worker. This worker code can be very similar to what the server-side version would be, it can even reuse express (although it probably shouldn't). Because the service worker's API for all intents and purposes looks like a server-side API to the frontend codebase, that frontend can be built in all the usual ways, except now with the guarantee that every server roundtrip completes in milliseconds.

To avoid slow page loads each time the web application is opened it also needs to be cached locally as part of the service worker. By packaging this as a progressive web app installs become possible onto the user's home screen, giving an experience not that unlike installing a native mobile app. The application server's job is now reduced to only providing that initial application install, and it can become a simple (and free) static web host.

Here comes trouble

Both the application and the data are now running locally, meaning the user doesn't need the network at all to get things done. This isn't just local-first, it is offline-first. But like all things in software architecture, we are trading one set of problems for another.

There still needs to be a way to get data onto other devices, or to other users, or simply backed up into the cloud. That means the service worker also gets the job of (asynchronously) uploading changes to a server API which will store it in a database, as well as pulling server-side changes back down. The server-side version acts as the master copy, and the server-side API gets the thankless job of merging client-side changes with it.

Not so fast though. Our user might be editing offline for a considerably long time, and the data on the server may have been changed in the meanwhile from another device or by another user. The job of merging those changes can be ... complicated. A good strategy is needed for merging. A possible path is to use CRDT algorithms from a library like automerge, as suggested by the local-first article linked above. However, for many cases a simpler bespoke algorithm that is aware of the specific data types being merged probably works well enough, as I discovered when making an offline-capable work orders web application that used this strategy.

As the local-first application is now directly talking to this API, it needs a way to authenticate. This can be a reason to still have a minimal backend-for-frontend on the server. An alternative is to use a separate OpenID Connect identity provider with a PKCE authorization flow to obtain an access token. PKCE is an authorization flow developed for use by mobile apps but also usable by web apps that enables secretless API authentication.

Another major caveat is that the entire codebase needs to be on the client, which necessitates keeping it under a tight performance and size budget. Careful curation of dependencies is key, and a javascript footprint that runs in the megabytes is verboten. Vanilla web development can be a solution here, with its emphasis on using the platform to its limits without bringing in dependencies. A lightweight framework like Lit or Preact will do as well.

This isn't just a theoretical exercise. The team at Superhuman built their better gmail than gmail more or less as described here, using React as the framework, and the thing that sets their web app apart is its blistering speed. You can go read how they did it on their blog (part 1, part 2) or listen to the retelling on the Syntax podcast.

Loco-first

But we can push it even further, in theory at least. In the previous iteration of the architecture we still have the application-specific database and syncing logic on the server, but what if we instead did away with every last piece of server-side application logic?

architecture diagram of a local-first application without server-side logic

The syncing engine now gets moved in the client, and what it uploads to the server are just files. That means all we need is a generic cloud drive, like Dropbox or Onedrive. These cloud drive services often support cross-origin API access with OpenID Connect authentication using PKCE. That means the service worker in the client doesn't need any application servers to upload its data to the cloud storage.

The onboarding experience becomes a choice screen where the user picks their cloud drive option. The application will redirect the user to the Dropbox or Onedrive or <insert vendor here> login page, and obtains an access token using PKCE authorization flow that is persisted locally. This token is used to connect to the user's cloud drive from inside the service worker. The application will bootstrap itself from whatever files are already in the drive, and will regularly synchronize with the current contents of the drive using its service worker logic.

Instead of a cloud drive, another option is the use of the File System Access API to get a handle to a local folder. The user can set up this folder as cloud-synced, or they can back it up and copy it to other devices using a method of their choice. This solution allows the app to have complete privacy, as the user's data never leaves their device or network. The caveat is incomplete browser support. Come on Safari and Firefox, do your bit, for privacy!

In a single user scenario, each of their devices uploads its own copy of the data, either as a history of actions or as the latest state. When a device's service worker wants to sync, it checks all of the files of the other devices to see if they have been modified. If they are, it downloads updates and merges them into the local IndexedDB. This is the same work as the previous architecture iteration did in its server-side syncing API, only now in reverse: instead of every device going to the server and presenting its copy to merge, it is the service worker going to each device's copy and checking if it needs merging.

There is no longer a master copy of the data, only one copy per device, all on equal footing, with an eventual consistency arising out of the devices merging from each other's copies. This is the same philosophy as CRDTs, or of the Operational Transformation algorithm that underpins Google Docs. No matter though, when you get deep enough into consistency models (I recommend Aphyr's writings or Martin Kleppmann's book), you'll realize that there is never such as a thing as a reliable master copy in a distributed system, even in cases where there is a central database.

A multi-user scenario can be realized by our user subscribing to someone else's shared cloud drive folder, where the other user shares their copy of the data as read-only. When Alice shares her folder with Bob, Bob shares his with Alice, and they both subscribe to each other's updates, then they can work on a shared document without ever having given each other direct write access.

As this application has no servers outside of the lightly loaded static web host, which in 2025 is free to host even at scale, the hosting cost drops to zero almost regardless of the number of users. The cloud drive costs are carried by each user individually, only responsible for their own share of storage. The only hosting cost to the application's owner would be the domain name. The climate footprint is minimal.

By eliminating the central database and its adjoining server-side logic it also eliminates the main target for hackers. Because compromises can only occur one user at a time they lack a sufficient reward for hacking the application. On top of that, the only servers are the static web host and the major cloud drive vendors, both practically impossible to hack. This web app would be highly secure and remain secure with barely any maintenance.

This architecture would be a cloud vendor's worst nightmare if it ever became popular, as they cannot earn a dime from it (aside from the basic cloud drive pennies). Thankfully for them, it is impossible to monetize for the company that builds such a web app as well. No server == no revenue opportunity. For now this is just a crazy theoretical exercise, far removed from where the leading frontend architecture voices are driving us, but the possibility fascinates me.

]]>
<![CDATA[The history of web application architecture]]> https://plainvanillaweb.com/blog/articles/2025-07-13-history-architecture/ 2025-07-13T12:00:00.000Z 2025-07-13T12:00:00.000Z I'm old enough to remember what it was like. I first got online in 1994, but by then I was using computers for a few years already. That means I was there for the whole ride, the entire history of web application architecture, from the before times to the present day. So this post will be an overview of the various ways to make a web app, past and present, and their relative trade-offs as I experienced them.

Just to set expectations right: this will cover architectures targeted primarily at proper applications, of the sort that work with user data, and where most screens have to be made unique based on that user data. Web sites where the different visitors are presented with identical pages are a different beast. Although many of these architectures overlap with that use case, it will not be the focus here.

Also, while I will be mentioning some frameworks, they are equally not the focus. Where you see a framework, you can slot in vanilla web development with a bespoke solution.

This will be a long one, so grab a snack, take some time, and let's get going.

Before time began

Back when people first went online, the ruling architecture was offline-first. It looked sort of like this:

architecture diagram of user sending a local file via e-mail

Our user would have a proper desktop application, written in C or C++ most likely, and that would work with local files on their local file system, all perfectly functional when offline. When they wanted to "go online" and share their work, they would dial in to the internet, start their e-mail client and patiently send an e-mail to their friend or colleague containing a copy of their file as an attachment.

This architecture was very simple, and it worked well in the absence of a reliable and fast internet connection. This was a good thing because at the time most people never had a reliable and fast internet connection. There were problems though. For one, the bootstrapping problem: how do you get everyone to have the application installed so they can open and edit the file they received via e-mail? Also, the syncing problem: how are changes kept in sync between multiple devices and users? Merging edits from different files that could have weeks of incompatible edits was always somewhere between frustrating and impossible.

Traditional server-side rendering

Web applications promised they would solve both problems. At first we built them like this:

architecture diagram of a traditional server-side rendered web application

The first thing we did was move all of the stuff from all of the users into a central database somewhere online. Because this database was shared between the users, it became a lot more easy to keep their changes in sync. If one user made an edit to something, everyone else would see it on the next refresh.

To allow users to actually get at this database we had to give them an application to do it. This new-fangled thing called a web application running on a web application server would take http requests coming from the user's browser, SQL query the database for the right set of stuff, and send back an entire web page. Every click, every submit, it would generate a whole new page.

Deployment of those early web applications was often suspiciously simple: someone would connect via FTP to a server, and copy over the files from their local machine. There often weren't even any build steps. There was no step 3.

On the one hand this architecture was convenient. It solved the bootstrapping problem by only demanding that each user have a web browser and an internet connection. It also moved all of the application logic over to the server, keeping it neatly in one place. Crucially it kept the browser's task minimal, important in an era where browsers were much less capable and PC's were orders of magnitude slower than today. If done well, it could even be used to make web applications that still worked with JavaScript disabled. My first mobile web app was like that, sneakily using HTML forms with multiple submit actions and hidden input fields to present interactive navigation through a CRUD interface.

On the other hand, it had many problems, especially early on. HTML wasn't very good, CSS was in its infancy, and early JavaScript was mostly useless. It was hard going building anything at all on the early web. On top of that, web developers were a new breed, and they had to relearn many of the architecture lessons their desktop developer colleagues had already learned through bitter experience. For example, everyone has heard of the adage "If you don't choose a framework, you'll end up building a worse one yourself." That is because for the first few years building your own terrible framework as you went was the norm, until everyone wisened up and started preaching this wisdom. For sure, my own first experiments in web application development in PHP 3 and 4 were all without the benefit of a proper framework.

Web developers also had to learn lessons that their desktop counterparts never had to contend with. Moving the application to the server was convenient, but it exposed it to hackers from all across the world, and the early web application landscape was riddled with embarrassing hacks. Because the threat level on the internet keeps rising this remains a major headache to this day.

Another novel problem was having to care a whole lot about connectivity and server uptime. Because users literally couldn't do anything at all if they didn't have a connection to a working web server, making sure that connection was always there became a pervasive headache. Going to a site and seeing an error 500 message was unsurprisingly common in those early years.

The biggest problems however were throughput, bandwidth and latency. Because almost every click had to reload the whole page, doing anything at all in those early web applications was slow, like really, really slow. At the time, servers were slow to render the page, networks slow to transport it, and PC's and browsers slow to render. That couldn't stand, so something had to change. It was at this point that we saw a fork in the road, and the web developer community split up into two schools of thought that each went their own way. Although, as you will see, they are drawing closer again.

Modern server-side rendering

One branch of the web development tree doubled down on server-side rendering, building further on top of the existing server-side frameworks.

architecture diagram of a server-side rendered application with liveview templates

They tackled the problems imposed by throughput and latency by moving over to a model of partial page updates, where small bits of user-activated JavaScript (originally mostly built with jQuery) would update parts of the page with HTML partials that they fetched from the server.

The evolution of this architecture are so called LiveViews. This is a design first popularized by the Phoenix framework for the obscure Elixir programming language, but quickly adopted in many places.

It uses framework logic to automatically wire up server-side generated templates with bits of JavaScript that will automatically call the server to fetch partial page updates when necessary. The developer has the convenience of not thinking about client-side scripting, while the users get an interactive user experience similar to a JavaScript-rich frontend. Typically the client keeps an open websocket connection to the server, so that server-side changes are quickly and automatically streamed into the page as soon as they occur.

This architecture is conceptually simple to work with as all the logic remains on the server. It also doesn't ask much from the browser, good for slow devices and bandwidth-constrained environments. As a consequence it finds a sweet spot in mostly static content-driven web sites.

But nothing is without trade-offs. This design hits the server on almost every interaction and has no path to offline functionality. Network latency and reliability are its UX killer, and especially on mobile phones – the main way people interact with web apps these days – those can still be a challenge. While this can be mitigated somewhat through browser caching, the limitation is always there. After all, the more that page content is dictated by realtime user input, the more necessary it becomes to push logic to the client. In cases where the network is good however, it can seem like magic even for highly interactive applications, and for that reason it has its diehard fans.

Client-side rendering

There was another branch of web development practice, let's say the ones who were fonder of clever architecture, who had a crazy thought: what if we moved rendering data to HTML from the server to the browser? They built new frameworks, in JavaScript, designed to run in the browser so that the application could be shipped as a whole as part of the initial page load, and every navigation would only need to fetch data for the new route. In theory this allowed for smaller and less frequent roundtrips to the server, and therefore an improvement to the user experience. Thanks to a blooming cottage industry of industry insiders advertising its benefits, it became the dominant architecture for new web applications, with React as the framework of choice to run inside the browser.

This method taken to its modern best practice extreme looks like this:

Architecture diagram of a client-side rendered single-page application

It starts out by moving the application, its framework, and its other dependencies over to the browser. When the page is loaded the entire bundle gets loaded, and then pages can be rendered as routes inside of the single-page application. Every time a new route needs to be rendered the data gets fetched from the server, not the HTML.

The web application server's job is now just providing the application bundle as a single HTML page, and providing API endpoints for loading JSON data for every route. Because those API endpoints naturally end up mirroring the frontend's routes this part usually gets called the backend-for-frontend. This job was so different from the old web server's role that a new generation of frameworks sprung up to be a better fit. Express on node became a very popular choice, as it allowed a great deal of similarity between browser and server codebases, although in practice there's usually not much actual code in common.

For security reasons – after all, the web application servers are on the increasingly dangerous public internet – the best practice became to host backends-for-frontend in a DMZ, a demilitarized zone where the assumption has to be that security is temporary and hostile interlopers could arrive at any time. In addition, if an organization has multiple frontends (and if they have a mobile app they probably have at least two), then this DMZ will contain multiple backends for frontend.

Because there is only a single database to share between those different BFFs, and because of the security risks of connecting to the database from the dangerous DMZ, a best practice became to keep the backend-for-frontend focused on just the part of serving the frontend, and to wrap the database in a separate thing. This separate microservice is an application whose sole job is publishing an API that gatekeeps access to the database. This API is usually in a separate network segment, shielded by firewalls or API gateways, and it is often built in yet another framework better tailored for building APIs, or even in a different programming language like Go or C#.

Of course, having only one microservice is kind of a lonely affair, so even organizations of moderate size would often end up having their backends-for-frontend each talking to multiple microservices.

That's just too many servers to manage, too many network connections to configure, too many builds to run, so people by and large stopped managing their own servers, either for running builds or for runtime hosting. Instead they moved to the cloud, where someone else manages the server, and hosted their backends as docker containers or serverless functions deployed by git-powered CI/CD pipelines. This made some people fabulously wealthy. After all, 74% of Amazon's profit is made from AWS, and over a third of Microsoft's from Azure. It is no accident that there is a persistent drumbeat that everyone should move everything to the cloud. Those margins aren't going to pad themselves.

Incidentally, microservices as database intermediary are also a thing in the world of server-side rendered applications, but in my personal observation those teams seem to choose this strategy less often. Equally incidentally, the word serverless in the context of serverless functions was and is highly amusing to me, since it requires just as many servers, if not more. (I know why it's called that way, that doesn't make it any less funny.)

On paper this client-side rendered architecture has many positive qualities. It is highly modular, which makes the work easy to split up across developers or teams. It pushes page rendering logic into the browser, creating the potential to have a low latency and high quality user experience. The layered nature of the backend and limited scope of the internet-facing backend-for-frontend forms a solid defensive moat against cyberattacks. And the cloud-hosted infrastructure is low effort to manage and easy to scale. A design like this is every architecture astronomer's dream, and I was for a while very enamored with it myself.

In practice though, it just doesn't work very well. It's just too complicated. For larger experienced teams in large organizations it can kind of sort of make sense, and it is no surprise that big tech is a heavy proponent of this architecture. But step away from web-scale for just a second and there's too many parts to build and deploy and keep track of, too many technologies to learn, too many hops a data request has to travel through.

The application's logic gets smeared out across three or more independent codebases, and a lot of overhead is created in keeping all of those in sync. Adding a single data field to a type can suddenly become a whole project. For one application I was working on I once counted in how many places a particular type was explicitly defined, and the tally reached lucky number 7. It is no accident that right around the time that this architecture peaked the use of monorepo tools to bundle multiple projects into a single repository peaked as well.

Go talk to some people just starting out with web development and see how lost they get in trying to figure out all of this stuff, learning all the technologies comprising the Rube Goldberg machine that produces a webpage at the end. See just how little time they have left to dedicate to learning vanilla HTML, CSS and JS, arguably the key things a beginner should be focusing on.

Moreover, the promise that moving the application entirely to the browser would improve the user experience mostly did not pan out. As applications built with client-side frameworks like React or Angular grew, the bundle to be shipped in a page load ballooned to megabytes in size. The slowest quintile of devices and network connections struggled mightily with these heavy JavaScript payloads. It was hoped that Moore's law would solve this problem, but the dynamics of how (mobile) device and internet provider markets work mean that it hasn't been, and that it won't be any time soon. It's not impossible to build a great user experience with this architecture, but you're starting from behind. Well, at least for public-facing web applications.

Client-side rendering with server offload

The designers of client-side frameworks were not wholly insensitive to the frustrations of developers trying to make client-side rendered single-page applications work well on devices and connections that weren't up to the job. They started to offload more and more of the rendering work back to the server. In situations where the content of a page is fixed, static site generation can execute the client-side framework at build time to pre-render pages to HTML. And for situations where content has a dynamic character, server-side rendering was reintroduced back into the mix to offload some of the rendering work back to the server.

The current evolution of these trends is the streaming single-page application:

architecture diagram of a streaming single-page application

In this architecture the framework runs the show in both backend-for-frontend and in the browser. It decides where the rendering happens, and only pushes the work to the browser that must run there. When possible the page is shipped prerendered to the browser and the code for the prerendered parts is not needed in the client bundle. Because some parts of the page are more dynamic than others, they can be rendered on-demand in the server and streamed to the browser where they are slotted into the prerendered page. The bundle that is shipped to the browser can be kept light-weight because it mostly just needs to respond to user input by streaming the necessary page updates from the server over an open websocket connection.

If that sounds suspiciously like the architecture for modern server-side rendering that I described before, that is because it basically is. While a Next.JS codebase is likely to have some client-rendered components still, the extreme of a best practice Astro codebase would see every last component rendered on the server. In doing that they arrive at something functionally no different from LiveView architecture, and with a similar set of trade-offs. These architectures are simpler to work with, but they perform poorly for dynamic applications on low reliability or high latency connections, and they cannot work offline.

Another major simplication of the architecture is getting rid of the database middleman. Microservices and serverless functions are not as hyped as they were, people are happy to build so-called monoliths again, and frameworks are happy to recommend they do so. The meta-frameworks now suggest that the API can be merged into the web application frontend, and the framework will know that those parts are only meant to be run on the server. This radically simplifies the codebase, we're back to a single codebase for the entire application managed by a single framework.

However, TANSTAAFL. This simplification comes at the expense of other things. The Next.JS documentation may claim "Since Server Components are rendered on the server, you can safely make database queries using an ORM or database client." but that doesn't mean that it's actually safe to allow the part that faces the internet to have a direct line to the database. Defense in depth was a good idea, and we're back to trading security for simplicity. There were other reasons that monoliths once fell out of favor. It's like we're now forgetting lessons that were already learned.

Where does that leave us?

So, which architecture should you pick? I wish I could tell you, but you should have understood by now that the answer was always going to be it depends. Riffing on the work of Tolstoy: all web architectures are alike in that they are unhappy in their own unique way.

In a sense, all of these architectures are also unhappy in the same way: there's a whole internet in between the user and their data. There's a golden rule in software architecture: you can't beat physics with code. We draw the internet on diagrams as a cute little cloud, pretending it is not a physical thing. But the internet is wires, and antennas, and satellites, and data centers, and all kinds of physical things and places. Sending a signal through all those physical things and places will always be somewhat unreliable and somewhat slow. We cannot reliably deliver on the promise of a great user experience as long as we put a cute little cloud in between the user and their stuff.

In the next article I'll be exploring an obscure but very different architecture, a crazy thought similar to that of client-side rendering: what happens when we move the user's data from the server back into the client? What is local-first web application architecture?

]]>
<![CDATA[Clean client-side routing]]> https://plainvanillaweb.com/blog/articles/2025-06-25-routing/ 2025-06-25T12:00:00.000Z 2025-06-25T12:00:00.000Z The main Plain Vanilla tutorial explains two ways of doing client-side routing. Both use old school anchor tags for route navigation. First is the traditional multi-page approach described on the Sites page as one HTML file per route, great for content sites, not so great for web applications. Second is the hash-based routing approach decribed on the Applications page, one custom element per route, better for web applications, but not for having clean URLs or having Google index your content.

In this article I will describe a third way, single-file and single-page but with clean URLs using the pushState API, and still using anchor tags for route navigation. The conceit of this technique will be that it needs more code, and the tiniest bit of server cooperation.

Intercepting anchor clicks

To get a true single-page experience the first thing we have to do is intercept link tag navigation and redirect them to in-page events. Our SPA can then respond to these events by updating its routes.

export const routerEvents = new EventTarget();

export const interceptNavigation = (root) => {
    // convert link clicks to navigate events
    root.addEventListener('click', handleLinkClick);
    // convert navigate events to pushState() calls
    routerEvents.addEventListener('navigate', handleNavigate);
}

const handleLinkClick = (e) => {
    const a = e.target.closest('a');
    if (a && a.href) {
        e.preventDefault();
        const anchorUrl = new URL(a.href);
        const pageUrl = anchorUrl.pathname + anchorUrl.search + anchorUrl.hash;
        routerEvents.dispatchEvent(new CustomEvent('navigate', { detail: { url: pageUrl, a }}));
    }
}

const handleNavigate = (e) => {
    history.pushState(null, null, e.detail.url);
}

In an example HTML page we can leverage this to implement routing in a <demo-app></demo-app> element.

import { routerEvents, interceptNavigation } from './view-route.js';

customElements.define('demo-app', class extends HTMLElement {

    #route = '/';

    constructor() {
        super();
        interceptNavigation(document.body);
        routerEvents.addEventListener('navigate', (e) => {
            this.#route = e.detail.url;
            this.update();
        });
    }

    connectedCallback() {
        this.update();
    }

    update() {
        if (this.#route === '/') {
            this.innerHTML = 'This is the homepage. <a href="/details">Go to the details page</a>.';
        } else if (this.#route === '/details') {
            this.innerHTML = 'This is the details page. <a href="/">Go to the home page</a>.';
        } else {
            this.innerHTML = `The page ${this.#route} does not exist. <a href="/">Go to the home page</a>.`;
        }
        this.innerHTML += `<br>Current route: ${this.#route}`;
    }
});

example 1

The first thing we're doing in view-route.js is the interceptNavigation() function. It adds an event handler at the top of the DOM that traps bubbling link clicks and turns them into a navigate event instead of the default action of browser page navigation. Then it also adds a navigate event listener that will update the browser's URL by calling pushState.

In app.js we can listen to the same navigate event to actually update the routes. Suddenly we've implemented a very basic in-page routing, but there are still a bunch of missing pieces.

There and back again

For one, browser back and forward buttons don't actually work. We can click and see the URL update in the browser, but the page does not respond. In order to do this, we need to start listening to popstate events.

However, this risks creating diverging code paths for route navigation, one for the navigate event and one for the popstate event. Ideally a single event listener responds to both types of navigation. A simplistic way of providing a single event to listen can look like this:

view-route.js (partial):

const handleNavigate = (e) => {
    history.pushState(null, null, e.detail.url);
    routerEvents.dispatchEvent(new PopStateEvent('popstate'));
}

// update routes on popstate (browser back/forward)
export const handlePopState = (e) => {
    routerEvents.dispatchEvent(new PopStateEvent('popstate', { state: e.state }));
}
window.addEventListener('popstate', handlePopState);

Now our views can respond to popstate events and update based on the current route. A second question then becomes: what is the current route? The popstate event does not carry that info. The window.location value does have that, and it is always updated as we navigate, but because it has the full URL it is cumbersome to parse. What is needed is a way of easily parsing it, something like this:

view-route.js (continued):

// ...

// all routes will be relative to the document's base path
const baseURL = new URL(window.originalHref || document.URL);
const basePath = baseURL.pathname.slice(0, baseURL.pathname.lastIndexOf('/'));

// returns an array of regex matches for matched routes, or null
export const matchesRoute = (path) => {
    const fullPath = basePath + '(' + path + ')';
    const regex = new RegExp(`^${fullPath.replaceAll('/', '\\/')}`, 'gi');
    const relativeUrl = location.pathname;
    return regex.exec(relativeUrl);
}

The matchesRoute() function accepts a regex to match as the route, and will wrap it so it is interpreted relative to the current document's URL, making all routes relative to our single page. Now we can clean up the application code leveraging these new generic routing features:

import { routerEvents, interceptNavigation, matchesRoute } from './view-route.js';

customElements.define('demo-app', class extends HTMLElement {

    #route = '/';

    constructor() {
        super();
        interceptNavigation(document.body);
        routerEvents.addEventListener('popstate', (e) => {
            const matches =
                matchesRoute('/details') || 
                matchesRoute('/');
            this.#route = matches?.[1];
            this.update();
        });
    }

    connectedCallback() {
        this.update();
    }

    update() {
        if (this.#route === '/') {
            this.innerHTML = 'This is the homepage. <a href="/details">Go to the details page</a>.';
        } else if (this.#route === '/details') {
            this.innerHTML = 'This is the details page. <a href="/">Go to the home page</a>.';
        } else {
            this.innerHTML = `The page ${this.#route} does not exist. <a href="/">Go to the home page</a>.`;
        }
        this.innerHTML += `<br>Current route: ${this.#route}`;
    }
});

example 2

Opening that in a separate tab we can see that the absolute URL neatly updates with the routes, that browser back/forwards navigation updates the view, and that inside the view the route is relative to the document.

Because matchesRoute() accepts a regex, it can be used to capture route components that are used inside of the view. Something like matchesRoute('/details/(?<id>[\\w]+)') would put the ID in matches.groups.id. It's simple, but it gets the job done.

Can you use it in a sentence?

While this rudimentary way of detecting routes works, adding more routes quickly becomes unwieldy. It would be nice to instead have a declarative way of wrapping parts of views inside routes. Enter: a custom element to wrap each route in the page's markup.

view-route.js (partial):

// ...

customElements.define('view-route', class extends HTMLElement {

    #matches = [];

    get isActive() {
        return !!this.#matches?.length;
    }

    get matches() {
        return this.#matches;
    }

    set matches(v) {
        this.#matches = v;
        this.style.display = this.isActive ? 'contents' : 'none';
        if (this.isActive) {
            this.dispatchEvent(new CustomEvent('routechange', { detail: v, bubbles: true }));
        }
    }

    connectedCallback() {
        routerEvents.addEventListener('popstate', this);
        this.update();
    }

    disconnectedCallback() {
        routerEvents.removeEventListener('popstate', this);
    }

    handleEvent(e) {
        this.update();
    }

    static get observedAttributes() {
        return ['path'];
    }

    attributeChangedCallback() {
        this.update();
    }

    update() {
        const path = this.getAttribute('path') || '/';
        this.matches = this.matchesRoute(path) || [];
    }

    matchesRoute(path) {
        // '*' triggers fallback route if no other route on the same DOM level matches
        if (path === '*') {
            const activeRoutes = 
                Array.from(this.parentNode.getElementsByTagName('view-route')).filter(_ => _.isActive);
            if (!activeRoutes.length) return [location.pathname, '*'];
        // normal routes
        } else {
            return matchesRoute(path);
        }
        return null;
    }
});

Now we can rewrite our app to be a lot more declarative, while preserving the behavior.

import { interceptNavigation } from './view-route.js';

customElements.define('demo-app', class extends HTMLElement {

    constructor() {
        super();
        interceptNavigation(document.body);
    }

    connectedCallback() {
        this.innerHTML = `
        <view-route path="/(?:index.html)?$">
            This is the homepage. <a href="/details">Go to the details page</a>, or
            travel a <a href="/unknown">path of mystery</a>.
        </view-route>
        <view-route path="/details">
            This is the details page. <a href="/">Go to the home page</a>.
        </view-route>
        <view-route path="*">
            The page does not exist. <a href="/">Go to the home page</a>.
        </view-route>
        `
    }
});

example 3

404 not found

While things now look like they work perfectly, the illusion is shattered upon reloading the page when it is on the details route. To get rid of the 404 error we need a handler that will redirect to the main index page. This is typically something that requires server-side logic, locking us out from simple static hosting like GitHub Pages, but thanks to the kindness of internet strangers, there is a solution.

It involves creating a 404.html file that GitHub will load for any 404 error (the tiny bit of server cooperation). In this file the route is encoded as a query parameter, the page redirects to index.html, and inside that index page the route is restored.

<!DOCTYPE html>
<html lang="en">
<head>
    <script>
        var pathSegmentsToKeep = window.location.hostname === 'localhost' ? 0 : 1;

        var l = window.location;
        l.replace(
            l.protocol + '//' + l.hostname + (l.port ? ':' + l.port : '') +
            l.pathname.split('/').slice(0, 1 + pathSegmentsToKeep).join('/') + '/?/' +
            l.pathname.slice(1).split('/').slice(pathSegmentsToKeep).join('/').replace(/&/g, '~and~') +
            (l.search ? '&' + l.search.slice(1).replace(/&/g, '~and~') : '') +
            l.hash
        );
    </script>
</head>
</html>

index.html:

<!doctype html>
<html lang="en">
<head>
    <!-- ... -->
</head>
<body>
    <script type="text/javascript">
        // preserve route
        window.originalHref = window.location.href;
        // decode from parameter passed by 404.html
        (function (l) {
            if (l.search[1] === '/') {
                var decoded = l.search.slice(1).split('&').map(function (s) {
                    return s.replace(/~and~/g, '&')
                }).join('?');
                window.history.replaceState(null, null,
                    l.pathname.slice(0, -1) + decoded + l.hash
                );
            }
        }(window.location))
    </script>
    
    <!-- ... -->
</body>
</html>

Adding this last piece to what we already had gets us a complete routing solution for vanilla single page applications that are hosted on GitHub Pages. Here's a live example hosted from there:

example 4

To full code of view-route.js and of this example is on GitHub.

]]>
<![CDATA[Bringing React's <ViewTransition> to vanilla JS]]> https://plainvanillaweb.com/blog/articles/2025-06-12-view-transitions/ 2025-06-12T12:00:00.000Z 2025-06-12T12:00:00.000Z I like React. I really do. It is the default answer for modern web development, and it is that answer for a reason. Generally when React adds a feature it is well thought through, within the React system of thinking. My one criticism is that React by its nature overthinks things, that dumber and simpler solutions would often be on the whole ... better. Less magic, more predictable.

So when I port framework features to vanilla JS, don't take this as a slight of that framework. It is meant as an exploration of what dumber and simpler solutions might look like, when built on the ground floor of the web's platform instead of the lofty altitudes of big frameworks. It is a great way to learn.

Which brings me of course to today's topic: view transitions, and how to implement them.

View Transitions 101

Let's start with the basics: what is a view transition?

example 1

In a supporting browser, what you'll see when you click is a square smoothly transitioning between blue and orange on every button click. By supported browser I mean Chrome, Edge or Safari, but sadly not yet Firefox, although they're working on it! In Firefox you'll see the change, but applied immediately without the animation.

At the code level, it looks something like this:

example.js:

function transition() {
    const square1 = document.getElementById('square1');
    if (document.startViewTransition) {
        document.startViewTransition(() => {
            square1.classList.toggle('toggled');
        });
    } else {
        square1.classList.toggle('toggled');
    }
}

transitions.css:

#square1 {
    background-color: orange;
}
#square1.toggled {
    background-color: blue;
}

How this works is that the browser takes a snapshot of the page when we call document.startViewTransition(), takes another snapshot after the callback passed to it is done (or the promise it returns fulfills), and then figures out how to smoothly animate between the two snapshots, using a fade by default.

A very nice thing is that by putting a view-transition-name style on an element we can make it transition independently from the rest of the page, and we can control that transition through CSS.

example.js:

function transition() {
    const square1 = document.getElementById('square1');
    const square2 = document.getElementById('square2');
    if (document.startViewTransition) {
        document.startViewTransition(() => {
            square1.classList.toggle('toggled');
            square2.classList.toggle('toggled');
        });
    } else {
        square1.classList.toggle('toggled');
        square2.classList.toggle('toggled');
    }
}

transitions.css:

#square2 {
    background-color: green;
    view-transition-name: slide;
    display: none;
}
#square2.toggled {
    display: inline-block;
}
::view-transition-new(slide):only-child {
    animation: 400ms ease-in both slide-in;
}
@keyframes slide-in {
    from { transform: translateY(-200px); }
    to { transform: translateY(0); }
}

Now we can see a second square sliding in on the first click, and fading out on the second.

example 2

That's enough view transition basics for now. If you're curious for more, you can learn the rest in the chrome developer documentation.

Here comes trouble

Up to this point, we've gotten the fair weather version of view transitions, but there are paper cuts.

  • Firefox doesn't support view transitions at all, so we have to feature-detect.
  • There is only one actual current View Transitions standard, level 1, but most of the online tutorials talk about the unfinalized level 2.
  • If there are duplicate values of view-transition-name anywhere on the page, the animations disappear in a puff of duplicate element error smoke.
  • As always, there's a thing about shadow DOM, but more on that later.
  • Starting a new view transition when one is already running skips to the end of the previous one, bringing the smooth user experience to a jarring end.
  • User input is blocked while the view is transitioning, causing frustration when clicks are ignored.
  • The document.startViewTransition() function only accepts a single callback that returns a single promise.

It is the last one that really spells trouble. In a larger single-page web application we'll typically find a central routing layer that triggers a number of asynchronous updates every time the route changes. Wrapping those asynchronous updates into a single promise can be a challenge, as is finding the right place to "slot in" a call to document.startViewTransition().

Also, we probably don't even want to wait for all of the asynchronous updates to complete. Leaving the application in an interactive state in between two smaller view transitions is better than bundling it all together into one ponderous picture perfect transition animation.

What React did

React being React they solve those problems through magic, through exceeding cleverness. You can read up on their approach to view transitions, but distilling it down it becomes this:

  • Anything that should take part separately in a view transition is wrapped in a <ViewTransition> component.
  • React will choose unique view-transition-name style values, which DOM elements to set them on, and when to set them. This can be controlled through the <ViewTransition> name and key props.
  • Any updates that should become part of a view transition are wrapped in a startTransition() call.
  • React automatically figures out when to call document.startViewTransition(), and what updates to put inside the callback. It also cleverly avoids starting new transitions when one is already running, so startTransition() can be called from multiple places safely. Oh, and by the way, it feature detects, obviously.

When you do all of that, you get magic.

A React view transition demo

Good luck figuring out how it works, or how to troubleshoot when the magic loses its shine. But that is the bar, that is the lofty goal of user experience to reach with a dumber and simpler reimagining as vanilla JS. So let's get cooking.

A fresh start

Our starting point is a barebones implementation of a startTransition() function to replace what React's startTransition() does. It will fall back to non-animated transitions if our browser doesn't support document.startViewTransition.

export const startTransition = (updateCallback) => {
    if (document.startViewTransition) {
        document.startViewTransition(updateCallback);
    } else {
        const done = Promise.try(updateCallback);
        return {
            updateCallbackDone: done,
            ready: done,
            finished: done,
            skipTransition: () => {}
        };
    }
}

example.js:

import { startTransition } from './view-transition.js';

export function transition() {
    startTransition(() => {
        document.getElementById('square1').classList.toggle('toggled');
        document.getElementById('square2').classList.toggle('toggled');
    });
}

example 3

While that takes care of feature-detecting, we can still run into timing issues. For example, let's say that instead of toggling we were switching routes, and the second route needs to load data prior to animating in.

So with HTML like this:


    <p><button>Navigate</button></p>
    <div id="route1" class="route"></div>
    <div id="route2" class="route"></div>
        

We might intuitively choose to do something like this:

example.js:

import { startTransition } from './view-transition.js';

let currentRoute = '';

export function navigate() {
    currentRoute = currentRoute === 'route2' ? 'route1' : 'route2';
    updateRoute1();
    updateRoute2();
}

function updateRoute1() {
    startTransition(() => {
        if (currentRoute === 'route1') {
            document.getElementById('route1').classList.add('active');
        } else {
            document.getElementById('route1').classList.remove('active');
        }
    });
}

function updateRoute2() {
    startTransition(() => {
        const route2 = document.getElementById('route2');
        if (currentRoute === 'route2') {
            route2.classList.add('active', 'loading');
            route2.textContent = '...';
            load().then((data) => startTransition(() => {
                route2.textContent = data;
                route2.classList.remove('loading');
            }));
        } else {
            document.getElementById('route2').classList.remove('active');
        }
    });
}

function load() {
    return new Promise((resolve) => {
        setTimeout(() => {
            resolve('Hi!');
        }, 250);
    });
}

example 4

But, as you see when trying it out, it doesn't work. Because the startTransition() calls end up overlapping each other, the animation is interrupted, and we get a jarring experience. While this toy example can be made to work by tuning delays, in the real world those same delays are network-based, so there's no timing-based solution. We also can't solve this by bundling everything into one single big view transition, because that would imply blocking user input while a network request completes, which would be a bad user experience.

React solves all of this in the typical React way. It will smartly choose how to batch work into successive calls to document.startViewTransition(). It will take into account where something loads lazily, as in the previous example, and batch the work of animating in the content for the fallback in a separate view transition.

Taking a queue

Distilling that approach to its essence, the really useful part of React's solution is the queueing and batching of work. Any call to startTransition() that occurs while a view transition is running should be queued until after the transition completes, and nested calls should have all their updates batched together.

view-transition.js:

// the currently animating view transition
let currentTransition = null;
// the next transition to run (after currentTransition completes)
let nextTransition = null;

/** start a view transition or queue it for later if one is already animating */
export const startTransition = (updateCallback) => {
    if (!updateCallback) updateCallback = () => {};
    // a transition is active
    if (currentTransition && !currentTransition.isFinished) {
        // it is running callbacks, but not yet animating
        if (!currentTransition.isReady) {
            currentTransition.addCallback(updateCallback);
            return currentTransition;
        // it is already animating, queue callback in the next transition
        } else {
            if (!nextTransition) {
                nextTransition = new QueueingViewTransition();
            }
            return nextTransition.addCallback(updateCallback);
        }
    // if no transition is active, start animating the new transition
    } else {
        currentTransition = new QueueingViewTransition();
        currentTransition.addCallback(updateCallback);
        currentTransition.run();
        // after it's done, execute any queued transition
        const doNext = () => {
            if (nextTransition) {
                currentTransition = nextTransition;
                nextTransition = null;
                currentTransition.run();
                currentTransition.finished.finally(doNext);
            } else {
                currentTransition = null;
            }
        }
        currentTransition.finished.finally(doNext);
        return currentTransition;
    }
}

// ...

The QueueingViewTransition implementation is a straightforward batching of callbacks, and a single call to document.startViewTransition() that executes them in order. It is not included in the text of this article for brevity's sake, but linked at the bottom instead.

Applying that queueing solution on top of the previous example's unchanged code, we suddenly see the magic of clean view transitions between dynamically loading routes.

example 5

Back to the top

So as I was saying at the top, I like porting framework features to vanilla JS as a way of learning and exploring dumber and simpler solutions. Which brings me to the playground for that learning, a full port of React's tour-de-force <ViewTransition> example to vanilla web code.

example 6

The full code of this example is on GitHub. Arguably the 300 lines of code in the lib/ folder of that example constitute a mini-framework, but fascinating to me is that you can get so much mileage out of such a small amount of library code, with the resulting single-page application being more or less the same number of lines as the React original.

That example also shows how to do a purely client-side router with clean URLs using pushState(). This blog post has however gone too long already, so I'll leave that for another time.

One more thing

Oh yeah, I promised to talk about the thing with shadow DOM, and I promised a custom element. Here is the thing with shadow DOM: when document.startViewTransition() is called from the light DOM, it cannot see elements inside the shadow DOM that need to transition independently, unless those elements are exposed as DOM parts and a view-transition-name style is set on them in the light DOM.

If the solution to that intrigues you, it's in the GitHub example repo as well as a <view-transition> custom element. If that sounds like a bunch of mumbo jumbo instead, join the club. Just one more reason to avoid shadow DOM.

]]>
<![CDATA[Making a new form control]]> https://plainvanillaweb.com/blog/articles/2025-05-09-form-control/ 2025-05-09T12:00:00.000Z 2025-05-09T12:00:00.000Z There are some things that a web developer knows they shouldn't attempt. Making clever use of contenteditable. Building custom form controls. Making complicated custom elements without a framework. But do we really know what we think we know? Why not try to do all three, just for fun? Could it really be that bad?

Narrator: it would indeed be that bad.

This article is building on the previous one on proper attribute/property relations in custom elements. Read that first if you haven't yet. In this piece we're taking it a step further to build a custom element that handles input. The mission is simple: implement a basic version of <input type="text" /> but with display: inline layout.

A simple element

Let's start by just throwing something against the wall and playing around with it.

customElements.define('input-inline', class extends HTMLElement {
    
    get value() {
        return this.getAttribute('value') ?? '';
    }
    set value(value) {
        this.setAttribute('value', String(value));
    }

    get name() {
        return this.getAttribute('name') ?? '';
    }
    set name(v) {
        this.setAttribute('name', String(v));
    }
    
    connectedCallback() {
        this.#update();
    }

    static observedAttributes = ['value'];
    attributeChangedCallback() {
        this.#update();
    }

    #update() {
        this.style.display = 'inline';
        if (this.textContent !== this.value) {
            this.textContent = this.value;
        }
        this.contentEditable = true;
    }
});

And here's how we use it:

<form>
    <p>
        My favorite colors are <input-inline name="color1" value="green"></input-inline> 
        and <input-inline name="color2" value="purple"></input-inline>.
    </p>
    <button type="submit">Submit</button>
</form>

demo1

This is simple, clean, and horribly broken. For one, the form cannot see these controls at all and submits the empty object.

Form-associated elements

To fix that, we have to make a form-associated custom element. This is done through the magic of the formAssociated property and ElementInternals.

input-inline.js:

customElements.define('input-inline', class extends HTMLElement {
    
    #internals;

    /* ... */

    constructor() {
        super();
        this.#internals = this.attachInternals();
        this.#internals.role = 'textbox';
    }
    
    /* ... */

    #update() {
        /* ... */
        this.#internals.setFormValue(this.value);
    }

    static formAssociated = true;
});

demo2

ElementInternals offers a control surface for setting the behavior of our custom element as part of a form. The this.#internals.role = 'textbox' assignment sets a default role that can be overridden by the element's user through the role attribute or property, just like for built-in form controls. By calling this.#internals.setFormValue every time the control's value changes the form will know what value to submit. But ... while the form does submit the values for our controls now, it does not see the changes we make. That's because we aren't responding to input yet.

Looking for input

Ostensibly responding to input is just adding a few event listeners in connectedCallback and removing them again in disconnectedCallback. But doing it that way quickly gets verbose. An easy alternative is to instead rely on some of the built-in event logic magic, namely that events bubble and that objects can be listeners too.

input-inline.js:

customElements.define('input-inline', class extends HTMLElement {
    
    #shouldFireChange = false;
    
    /* ... */

    constructor() {
        /* ... */
        this.addEventListener('input', this);
        this.addEventListener('keydown', this);
        this.addEventListener('paste', this);
        this.addEventListener('focusout', this);
    }

    handleEvent(e) {
        switch (e.type) {
            // respond to user input (typing, drag-and-drop, paste)
            case 'input':
                this.value = cleanTextContent(this.textContent);
                this.#shouldFireChange = true;
                break;
            // enter key should submit form instead of adding a new line
            case 'keydown':
                if (e.key === 'Enter') {
                    e.preventDefault();
                    this.#internals.form?.requestSubmit();
                }
                break;
            // prevent pasting rich text (firefox), or newlines (all browsers)
            case 'paste':
                e.preventDefault();
                const text = e.clipboardData.getData('text/plain')
                    // replace newlines and tabs with spaces
                    .replace(/[\n\r\t]+/g, ' ')
                    // limit length of pasted text to something reasonable
                    .substring(0, 1000);
                // shadowRoot.getSelection is non-standard, fallback to document in firefox
                // https://stackoverflow.com/a/70523247
                let selection = this.getRootNode()?.getSelection?.() || document.getSelection();
                let range = selection.getRangeAt(0);
                range.deleteContents();
                range.insertNode(document.createTextNode(text));
                // manually trigger input event to restore default behavior
                this.dispatchEvent(new Event('input', { bubbles: true, composed: true }));
                break;
            // fire change event on blur
            case 'focusout':
                if (this.#shouldFireChange) {
                    this.#shouldFireChange = false;
                    this.dispatchEvent(new Event('change', { bubbles: true, composed: true }));
                }
                break;
        }
    }
    
    /* ... */
});

function cleanTextContent(text) {
    return (text ?? '')
        // replace newlines and tabs with spaces
        .replace(/[\n\r\t]+/g, ' ');
}

demo3

I prefer this pattern because it simplifies the code a lot compared to having separate handler functions. Attaching event listeners in the constructor instead of attaching and detaching them in the lifecycle callbacks is another simplification. It may seem like blasphemy to never clean up the event listeners, but DOM event listeners are weakly bound and garbage collection of the element can still occur with them attached. So this is fine.

In the event handler logic there's some verbosity to deal with the fallout of working with contenteditable. As this code is not the focus of this article, I won't dally on it except to remark that contenteditable is still just as annoying as you thought it was.

With these changes our element will now also emit input and change events just like a built-in HTML form control. But, you may have noticed another issue has cropped up. The standard form reset button does not actually reset the form.

Read the instructions

You see, when we said static formAssociated = true we entered into a contract to faithfully implement the expected behavior of a form control. That means we have a bunch of extra work to do.

input-inline.js:

customElements.define('input-inline', class extends HTMLElement {
    
    /* ... */

    #formDisabled = false;
    #value;

    set value(v) {
        if (this.#value !== String(v)) {
            this.#value = String(v);
            this.#update();    
        }
    }
    get value() {
        return this.#value ?? this.defaultValue;
    }

    get defaultValue() {
        return this.getAttribute('value') ?? '';
    }
    set defaultValue(value) {
        this.setAttribute('value', String(value));
    }

    set disabled(v) {
        if (v) {
            this.setAttribute('disabled', 'true');
        } else {
            this.removeAttribute('disabled');
        }
    }
    get disabled() {
        return this.hasAttribute('disabled');
    }

    set readOnly(v) {
        if (v) {
            this.setAttribute('readonly', 'true');
        } else {
            this.removeAttribute('readonly');
        }
    }
    get readOnly() {
        return this.hasAttribute('readonly');
    }

    /* ... */

    static observedAttributes = ['value', 'disabled', 'readonly'];
    attributeChangedCallback() {
        this.#update();
    }

    #update() {
        this.style.display = 'inline';
        this.textContent = this.value;
        this.#internals.setFormValue(this.value);

        const isDisabled = this.#formDisabled || this.disabled;
        this.#internals.ariaDisabled = isDisabled;
        this.#internals.ariaReadOnly = this.readOnly;
        this.contentEditable = !this.readOnly && !isDisabled && 'plaintext-only';
        this.tabIndex = isDisabled ? -1 : 0;
    }

    static formAssociated = true;

    formResetCallback() {
        this.#value = undefined;
        this.#update();
    }
    
    formDisabledCallback(disabled) {
        this.#formDisabled = disabled;
        this.#update();
    }
    
    formStateRestoreCallback(state) {
        this.#value = state ?? undefined;
        this.#update();
    }
});

/* ... */

demo4

There's a LOT going on there. It's too much to explain, so let me sum up.

  • The value attribute now corresponds to a defaultValue property, which is the value shown until changed and also the value that the form will reset the field to.
  • The value property contains only the modified value and does not correspond to an attribute.
  • The control can be marked disabled or read-only through attribute or property.
  • The form callbacks are implemented, so the control can be reset to its default value, will restore its last value after back-navigation, and will disable itself when it is in a disabled fieldset.

With some style

Up to this point we've been using some stand-in styling. However, it would be nice to have some default styling that can be bundled with our custom form control. Something like this:

/* default styling has lowest priority */
@layer {
    :root {
        --input-inline-border-color: light-dark(rgb(118, 118, 118), rgb(161, 161, 161));
        --input-inline-border-color-hover: light-dark(rgb(78, 78, 78), rgb(200, 200, 200));
        --input-inline-border-color-disabled: rgba(150, 150, 150, 0.5);
        --input-inline-text-color: light-dark(fieldtext, rgb(240, 240, 240));
        --input-inline-text-color-disabled: light-dark(rgb(84, 84, 84), rgb(170, 170, 170));
        --input-inline-bg-color: inherit;
        --input-inline-bg-color-disabled: inherit;
        --input-inline-min-width: 4ch;
    }

    input-inline {
        display: inline;
        background-color: var(--input-inline-bg-color);
        color: var(--input-inline-text-color);
        border: 1px dotted var(--input-inline-border-color);
        padding: 2px 3px;
        margin-bottom: -2px;
        border-radius: 3px;
        /* minimum width */
        padding-right: max(3px, calc(var(--input-inline-min-width) - var(--current-length)));

        &:hover {
            border-color: var(--input-inline-border-color-hover);
        }
    
        &:disabled {
            border-color: var(--input-inline-border-color-disabled);
            background-color: var(--input-inline-bg-color-disabled);
            color: var(--input-inline-text-color-disabled);
            -webkit-user-select: none;
            user-select: none;
        }
    
        &:focus-visible {
            border-color: transparent;
            outline-offset: 0;
            outline: 2px solid royalblue; /* firefox */
            outline-color: -webkit-focus-ring-color; /* the rest */
        }
    }

    @media screen and (-webkit-min-device-pixel-ratio:0) {
        input-inline:empty::before {
            /* fixes issue where empty input-inline shifts left in chromium browsers */
            content: " ";
        }
    }

}

demo5

The styles are isolated by scoping them to the name of our custom element, and the use of @layer puts them at the lowest priority, so that any user style will override the default style, just like for the built-in form controls. The use of variables offers an additional way to quickly restyle the control.

In the styling we also see the importance of properly thinking out disabled and focused state behavior. The upside and downside of building a custom form control is that we get to implement all the behavior that's normally built-in to the browser.

We're now past the 150 lines mark, just to get to the low bar of implementing the browser's mandatory form control behavior. So, are we done? Well, not quite. There's still one thing that form controls do, and although it's optional it's also kind of required.

Validation

Built-in form controls come with a validity API. To get an idea of what it means to implement it in a custom form control, let's add one validation attribute: required. It doesn't seem like it should take a lot of work, right?

input-inline.js:

let VALUE_MISSING_MESSAGE = 'Please fill out this field.';
(() => {
    const input = document.createElement('input');
    input.required = true;
    input.reportValidity();
    VALUE_MISSING_MESSAGE = input.validationMessage;
})();

const isSafari = /^((?!chrome|android).)*safari/i.test(navigator.userAgent);

customElements.define('input-inline', class extends HTMLElement {
    
    /* ... */

    #customValidityMessage = '';

    /* ... */

    set required(v) {
        if (v) {
            this.setAttribute('required', 'true');
        } else {
            this.removeAttribute('required');
        }
    }
    get required() {
        return this.hasAttribute('required');
    }

    /* ... */

    static observedAttributes = ['value', 'disabled', 'readonly', 'required'];
    attributeChangedCallback() {
        this.#update();
    }

    #update() {
        /* ... */

        this.#internals.ariaRequired = this.required;
        this.#updateValidity();
    }

    /* ... */

    #updateValidity() {
        const state = {};
        let message = '';

        // custom validity message overrides all else
        if (this.#customValidityMessage) {
            state.customError = true;
            message = this.#customValidityMessage;
        } else {
            if (this.required && !this.value) {
                state.valueMissing = true;
                message = VALUE_MISSING_MESSAGE;
            }
    
            // add other checks here if needed (e.g., pattern, minLength)
        }

        // safari needs a focusable validation anchor to show the validation message on form submit
        // and it must be a descendant of the input
        let anchor = undefined;
        if (isSafari) {
            anchor = this.querySelector('span[aria-hidden]');
            if (!anchor) {
                anchor = document.createElement('span');
                anchor.ariaHidden = true;
                anchor.tabIndex = 0;
                this.append(anchor);
            }
        }

        this.#internals.setValidity(state, message, anchor);
    }

    checkValidity() {
        this.#updateValidity();
        return this.#internals.checkValidity();
    }

    reportValidity() {
        this.#updateValidity();
        return this.#internals.reportValidity();
    }

    setCustomValidity(message) {
        this.#customValidityMessage = message ?? '';
        this.#updateValidity();
    }

    get validity() {
        return this.#internals.validity;
    }

    get validationMessage() {
        return this.#internals.validationMessage;
    }

    get willValidate() {
        return this.#internals.willValidate;
    }
});

/* ... */

demo6

The code for the example is exactly like it would be for built-in controls:

<form>
    <p>
        My favorite color is <input-inline name="color1" value="green" required></input-inline>.
    </p>
    <button type="submit">Submit</button>
    <button type="reset">Reset</button>
</form>

The ElementInternals interface is doing a lot of the work here, but we still have to proxy its methods and properties. You can tell however that by this point we're deep in the weeds of custom elements, because of the rough edges.

  • The example is using the input-inline:invalid style instead of :user-invalid because :user-invalid is not supported on custom elements yet.
  • An ugly hack is needed to get the properly localized message for a required field that matches that of built-in controls.
  • Safari flat-out won't show validation messages on non-shadowed form-associated custom elements if we don't give it an anchor to set them to, requiring another ugly hack.

In conclusion

We've established by now that it is indeed feasible to build a custom form control and have it take part in regular HTML forms, but also that it is a path surrounded by peril as well as laborious to travel. Whether it is worth doing is in the eye of the beholder.

Along that path we also learned some lessons on how to handle input in custom elements, and have proven yet again that contenteditable, while less painful than it once was, is an attribute that can only be used in anger.

Regardless, the full source code of the input-inline form control is on GitHub.

]]>
<![CDATA[The attribute/property duality]]> https://plainvanillaweb.com/blog/articles/2025-04-21-attribute-property-duality/ 2025-04-21T12:00:00.000Z 2025-04-21T12:00:00.000Z Web components, a.k.a. custom elements, are HTML elements that participate in HTML markup. As such they can have attributes:

<my-hello value="world"></my-hello>

But, they are also JavaScript objects, and as such they can have object properties.

let myHello = document.querySelector('my-hello'); myHello.value = 'foo';

And here's the tricky part about that: these are not the same thing! In fact, custom element attributes and properties by default have zero relationship between them, even when they share a name. Here's a live proof of this fact:

customElements.define('my-hello', class extends HTMLElement {
    connectedCallback() {
        this.textContent = `Hello, ${ this.value || 'null' }!`;
    }
});

Demo: no connection between attribute and property

Now, to be fair, we can get at the attribute value just fine from JavaScript:

customElements.define('my-hello', class extends HTMLElement {
    connectedCallback() {
        this.textContent = `Hello, ${ this.getAttribute('value') || 'null' }!`;
    }
});

Demo: displaying the attribute

But what if we would also like it to have a value property? What should the relationship between attribute and property be like?

  • Does updating the attribute always update the property?
  • Does updating the property always update the attribute?
  • When updates can go either way, does the property read and update the value of the attribute, or do both attribute and property wrap around a private field on the custom element's class?
  • When updates can go either way, how to avoid loops where the property updates the attribute, which updates the property, which...
  • When is it fine to have just an attribute without a property, or a property without an attribute?

In framework-based code, we typically don't get a say in these things. Frameworks generally like to pretend that attributes and properties are the same thing, and they automatically create code to make sure this is the case. In vanilla custom elements however, not only do we get to decide these things, we must decide them.

Going native

Seasoned developers will intuitively grasp what the sensible relationship between attributes and properties should be. This is because built-in HTML elements all implement similar kinds of relationships between their attributes and their properties. To explore that in depth, I recommend reading Making Web Component properties behave closer to the platform. Without fully restating that article, here's a quick recap:

  • Properties can exist independent of an attribute, but an attribute will typically have a related property.
  • If changing the attribute updates the property, then updating the property will update the attribute.
  • Properties reflect either an internal value of an element, or the value of the corresponding attribute.
  • Assigning a value of an invalid type will coerce the value to the right type, instead of rejecting the change.
  • Change events are only dispatched for changes by user input, not from programmatic changes to attribute or property.

An easy way to get much of this behavior is to make a property wrap around an attribute:

customElements.define('my-hello', class extends HTMLElement {
    get value() {
        return this.getAttribute('value');
    }
    set value(v) {
        this.setAttribute('value', String(v));
    }
    
    static observedAttributes = ['value'];
    attributeChangedCallback() {
        this.textContent = `Hello, ${ this.value || 'null' }!`;
    }
});

Demo: property wraps attribute

Notice how updating the property will update the attribute in the HTML representation, and how the property's assigned value is coerced into the attribute's string type. Attributes are always strings.

Into the weeds

Up to this point, things are looking straightforward. But this is web development, things are never as straightforward as they seem. For instance, what boolean attribute value should make a corresponding boolean property become true? The surprising but standard behavior on built-in elements is that any attribute value will be interpreted as true, and only the absence of the attribute will be interpreted as false.

Time for another iteration of our element:

customElements.define('my-hello', class extends HTMLElement {
    get value() {
        return this.getAttribute('value');
    }
    set value(v) {
        this.setAttribute('value', String(v));
    }

    get glam() {
        return this.hasAttribute('glam');
    }
    set glam(v) {
        if (v) {
            this.setAttribute('glam', 'true');
        } else {
            this.removeAttribute('glam');
        }
    }
    
    static observedAttributes = ['value', 'glam'];
    attributeChangedCallback() {
        this.textContent = 
            `Hello, ${ this.value || 'null' }!` +
            (this.glam ? '!!@#!' : '');
    }
});

Demo: adding a boolean property

Which leaves us with the last bit of tricky trivia: it's possible for the custom element's class to be instantiated and attached to the element after the property is assigned. In that case the property's setter is never called, and the attribute is not updated.

// html:
<my-hello value="world"></my-hello>
// js:
const myHello = document.querySelector('my-hello');
myHello.value = 42; // setter not called before define
customElements.define('my-hello', /* ... */);
console.log(myHello.getAttribute('value')); // -> "world"

This can be avoided by reassigning any previously set properties when the element is connected:

customElements.define('my-hello', class extends HTMLElement {
    get value() {
        return this.getAttribute('value');
    }
    set value(v) {
        this.setAttribute('value', String(v));
    }

    get glam() {
        return this.hasAttribute('glam');
    }
    set glam(v) {
        if (v) {
            this.setAttribute('glam', 'true');
        } else {
            this.removeAttribute('glam');
        }
    }
    
    static observedAttributes = ['value', 'glam'];
    attributeChangedCallback() {
        this.textContent = 
            `Hello, ${ this.value || 'null' }!` +
            (this.glam ? '!!@#!' : '');
    }

    connectedCallback() {
        this.#upgradeProperty('value');
        this.#upgradeProperty('glam');
    }

    #upgradeProperty(prop) {
        if (this.hasOwnProperty(prop)) {
            let value = this[prop];
            delete this[prop];
            this[prop] = value;
        }
    }
});

In conclusion

If that seems like a lot of work to do a very simple thing, that is because it is. The good news is: we don't have to always do this work.

When we're using web components as framework components in a codebase that we control, we don't have to follow any of these unwritten rules and can keep the web component code as simple as we like. However, when using web components as custom elements to be used in HTML markup then we do well to follow these best practices to avoid surprises, especially when making web components that may be used by others. YMMV.

In the next article, I'll be looking into custom elements that accept input, and how that adds twists to the plot.

]]>
<![CDATA[New year's resolve]]> https://plainvanillaweb.com/blog/articles/2025-01-01-new-years-resolve/ 2025-01-01T12:00:00.000Z 2025-01-01T12:00:00.000Z Today I was looking at what I want to write about in the coming year, and while checking out custom element micro-frameworks came across import.meta.resolve in the documentation for the Ponys micro-framework. That one simple trick is part of the essential toolbox that allows skipping build-time bundling, unlocking the vanilla web development achievement in the browser.

We need this toolbox because the build-time bundler does a lot of work for us:

  • Combining JS and CSS files to avoid slow page loads.
  • Resolving paths to the JS and CSS dependencies so we can have clean import statements.
  • Optimizing the bundled code, by stripping out whitespace, and removing unused imports thanks to a tree shaking algorithm.

It is not immediately apparent how to get these benefits without using a bundler.

Combining JS and CSS files

A typical framework-based web project contains hundreds or thousands of files. Having all those files loaded separately on a page load would be intolerably slow, hence the need for a bundler to reduce the file count. Even by stripping away third party dependencies we can still end up with dozens or hundreds of files constituting a vanilla web project.

When inspecting a page load in the browser's developer tools, we would then expect to see a lot of this:

a waterfall of network requests in browser devtools over http1

The browser would download 6 files at a time and the later requests would block until those files downloaded. This limitation of HTTP/1 let to not just the solution of bundlers to reduce file count, but because the limitation of 6 parallel downloads was per domain it also led to the popularity of CDN networks which allowed cheating and downloading 12 files at once instead of 6.

However. It's 2025. What you're likely to see in this modern era is more like this:

parallel network requests in browser devtools over http2

Because almost all web servers have shifted over to HTTP/2, which no longer has this limitation of only having 6 files in flight at a time, we now see that all the files that are requested in parallel get downloaded in parallel. There's still a small caveat on lossy connections called head-of-line-blocking, fixed in HTTP/3, which is presently starting to roll out to web servers across the internet.

But the long and short of it is this: requesting a lot of files all at once just isn't that big of a problem anymore. We don't need to bundle up the files in a vanilla web project until the file counts get ridiculous.

Resolving paths

Another thing that bundlers do is resolving relative paths pointing to imported JS and CSS files. See, for example, the elegance of CSS modules for importing styles into a react component from a relative path:

layout.tsx (React):

import styles from './styles.module.css'
 
export default function Layout({
  children,
}: {
  children: React.ReactNode
}) {
  return <section className={styles.dashboard}>{children}</section>
}

However. It's 2025. And our browsers now have a modern vanilla toolbox for importing. When we bootstrap our JS with the magic incantation <script src="index.js" type="module"></script> we unlock the magic ability to import JS files using ES module import notation:

index.js:

import { registerAvatarComponent } from './components/avatar.js';
const app = () => {
    registerAvatarComponent();
}
document.addEventListener('DOMContentLoaded', app);

Inside of such files we also get access to import.meta.url, the URL of the current JS file, and import.meta.resolve(), a function that resolves a path relative to the current file, even a path to a CSS file:

class Layout extends HTMLElement {
    constructor() {
        super();
        this.attachShadow({ mode: 'open' });
        this.shadowRoot.innerHTML = `
            <link rel="stylesheet" href="${import.meta.resolve('styles.css')}">
            <section class="dashboard"><slot></slot></section>
        `;
    }
}

export const registerLayoutComponent = 
    () => customElements.define('x-layout', Layout);

While not quite the same as what the bundler does, it still enables accessing any file by its relative path, and that in turn allows organizing projects in whatever way we want, for example in a feature-based folder organization. All without needing a build step.

This ability to do relative imports can be super-charged by import maps, which decouple the name of what is imported from the path of the file it is imported from, again all without involving a build step.

Optimizing bundled code

Another thing bundlers can do is optimizing the bundled code, by splitting the payload into things loaded initially, and things loaded later on lazily. And also by minifying it, stripping away unnecessary whitespace and comments so it will load faster.

However. It's 2025. We can transparently enable gzip or brotli compression on the server, and as it turns out that gets almost all the benefit of minifying. While minifying is nice, gzipping is what we really want, and we can get that without a build step.

And lazy loading, that works fine using dynamic import, and with a bit due diligence we can put some of the code behind such an import statement. I wrote before how React's lazy and suspense can be ported easily to vanilla web components.

Happy new year!

Great news! It's 2025, and the browser landscape is looking better than ever. It gives us enough tools that for many web projects we can drop the bundler and do just fine. You wouldn't believe it based on what the mainstream frameworks are doing though. Maybe 2025 is the year we finally see a wide recognition of just how powerful the browser platform has gotten, and a return to old school simplicity in web development practice, away from all those complicated build steps. It's my new year's resolve to do my part in spreading the word.

]]>
<![CDATA[Caching vanilla sites]]> https://plainvanillaweb.com/blog/articles/2024-12-16-caching-vanilla-sites/ 2024-12-16T12:00:00.000Z 2024-12-16T12:00:00.000Z If you go to a typical website built with a framework, you'll see a lot of this:

browser devtools showing network requests for vercel.com

Those long cryptic filenames are not meant to discourage casual snooping. They're meant to ensure the filename is changed every time a single byte in that file changes, because the site is using far-future expire headers, a technique where the browser is told to cache files indefinitely, until the end of time. On successive page loads those resources will then always be served from cache. The only drawback is having to change the filename each time the file's contents change, but a framework's build steps typically take care of that.

For vanilla web sites, this strategy doesn't work. By abandoning a build step there is no way to automatically generate filenames, and unless nothing makes you happier than renaming all files manually every time you deploy a new version, we have to look towards other strategies.

How caching works

Browser cache behavior is complicated, and a deep dive into the topic deserves its own article. However, very simply put, what you'll see is mostly these response headers:

Cache-Control

The cache control response header determines whether the browser should cache the response, and how long it should serve the response from cache.

Cache-Control: public, max-age: 604800

This will cache the resource and only check if there's a new version after one week.

Age

The max-age directive does not measure age from the time that the response is received, but from the time that the response was originally served:

Age: 10

This response header indicates the response was served on the origin server 10 seconds ago.

Etag

The Etag header is a unique hash of the resource's contents, an identifier for that version of the resource.

ETag: "33a64df551425fcc55e4d42a148795d9f25f89d4"

When the browser requests that resource again from the server, knowing the Etag it can pass an If-None-Match header with the Etag's value. If the resource has not changed it will still have the same Etag value, and the server will respond with 304 Not Modified.

If-None-Match: "33a64df551425fcc55e4d42a148795d9f25f89d4"

Last-Modified

The Last-Modified header works similarly to Etag, except instead of sending a hash of the contents, it sends a timestamp of when the resource was last changed. Like Etag's If-None-Match it is matched by the If-Modified-Since header when requesting the resource from the server again.

With that basic review of caching headers, let's look at some strategies for making good use of them in vanilla web projects.

Keeping it simple: GitHub Pages

The simplest strategy is what GitHub Pages does: cache files for 10 minutes. Every file that's downloaded has Cache-Control: max-age headers that make it expire 10 minutes into the future. After that if the file is loaded again it will be requested from the network. The browser will add If-None-Match or If-Modified-Since headers to allow the server to avoid sending the file if it hasn't been changed, saving bytes but not a roundtrip.

If you want to see it in action, just open the browser devtools and reload this page.

browser devtools showing network requests for plainvanillaweb.com

Visitors never get a page that is more than 10 minutes out of date, and as they navigate around the site they mostly get fast cache-served responses. However, on repeat visits they will get a slow first-load experience. Also, if the server updates in the middle of a page load then different resources may end up mismatched and belong to a different version of the site, causing unpredictable bugs. Well, for 10 minutes at least.

Extending cache durations

While the 10 minute cache policy is ok for HTML content and small JS and CSS files, it can be improved by increasing cache times on large resources like libraries and images. By using a caching proxy that allows setting rules on specific types or folders of files we can increase the cache duration. For sites proxied through Cloudflare, their cache customization settings can be used to set these resource-specific policies.

By setting longer cache durations on some resources, we can ensure they're served from local cache more often. However, what to do if the resource changes? In those cases we need to modify the fetched URL of the resource every place that it is referred to. For example, by appending a unique query parameter:

<img src="image.jpg?v=2" alt="My cached image" />

The awkward aspect of having to change the referred URL in every place that a changed file is used makes extending cache durations inconvenient for files that are changed often or are referred in many places.

Also, applying such policies to JavaScript or CSS becomes a minefield, because a mismatched combination of JS or CSS files could end up in the browser cache indefinitely, breaking the website for the user until URL's are changed or their browser cache is cleared. For that reason, I don't think it's prudent to do this for anything but files that never change or that have some kind of version marker in their URL.

Complete control with service workers

A static web site can take complete control over its cache behavior by using a service worker. The service worker intercepts every network request and then decides whether to serve it from a local cache or from the network. For example, here's a service worker that will cache all resources indefinitely, until its version is changed:

let cacheName = 'cache-worker-v1';
// these are automatically cached when the site is first loaded
let initialAssets = [
    './',
    'index.html',
    'index.js',
    'index.css',
    'manifest.json',
    'android-chrome-512x512.png',
    'favicon.ico',
    'apple-touch-icon.png',
    'styles/reset.css',
    // the rest will be auto-discovered
];

// initial bundle (on first load)
self.addEventListener('install', (event) => {
    event.waitUntil(
        caches.open(cacheName).then((cache) => {
            return cache.addAll(initialAssets);
        })
    );
});

// clear out stale caches after service worker update
self.addEventListener('activate', (event) => {
    event.waitUntil(
        caches.keys().then((cacheNames) => {
            return Promise.all(
                cacheNames.map((cacheName) => {
                    if (cacheName !== self.cacheName) {
                        return caches.delete(cacheName);
                    }
                })
            );
        })
    );
});

// default to fetching from cache, fallback to network
self.addEventListener('fetch', (event) => {
    const url = new URL(event.request.url);

    // other origins bypass the cache
    if (url.origin !== location.origin) {
        networkOnly(event);
    // default to fetching from cache, and updating asynchronously
    } else {
        staleWhileRevalidate(event);
    }
});

const networkOnly = (event) => {
    event.respondWith(fetch(event.request));
}

// fetch events are serviced from cache if possible, but also updated behind the scenes
const staleWhileRevalidate = (event) => {
    event.respondWith(
        caches.match(event.request).then(cachedResponse => {
            const networkUpdate = 
                fetch(event.request).then(networkResponse => {
                    caches.open(cacheName).then(
                        cache => cache.put(event.request, networkResponse));
                    return networkResponse.clone();
                }).catch(_ => /*ignore because we're probably offline*/_);
            return cachedResponse || networkUpdate;
        })
    );
}

This recreates the far-future expiration strategy but does it client-side, inside the service worker. Because only the version at the top of the sw.js file needs to be updated when the site's contents change, this becomes practical to do without adding a build step. However, because the service worker intercepts network requests to change their behavior there is a risk that bugs could lead to a broken site, so this strategy is only for the careful and well-versed. (And no, the above service worker code hasn't been baked in production, so be careful when copying it to your own site.)

Wrapping up

Setting sane cache policies meant to optimize page load performance is one of the things typically in the domain of full-fat frameworks or application servers. But, abandoning build steps and server-side logic does not necessarily have to mean having poor caching performance. There are multiple strategies with varying amounts of cache control, and there is probably a suitable strategy for any plain vanilla site.

Last but not least, an even better way to speed up page loading is to keep the web page itself light. Using a plain vanilla approach to pages with zero dependencies baked into the page weight already puts you in pole position for good page load performance, before caching even enters the picture.

]]>
<![CDATA[Editing Plain Vanilla]]> https://plainvanillaweb.com/blog/articles/2024-10-20-editing-plain-vanilla/ 2024-10-20T12:00:00.000Z 2024-10-20T12:00:00.000Z I'm typing this up in Visual Studio Code, after realizing I should probably explain how I use it to make this site. But the whole idea behind Plain Vanilla is no build, no framework, no tools. And VS Code is definitely a tool. So what gives?

There's a difference between tools that sit in between the code and a deployed site, and tools that only act in support of editing or deploying. The first category, like npm or typescript, impose continued maintenance on the project because they form a direct dependency. The second category, like VS Code or git, are easily replaced without impacting the project's ability to be edited. There's a tension between the ability to get things done faster, and the burden created by additional dependencies. For this project I draw the line in accepting the second category while rejecting the first.

Setting up a profile

I use VS Code for framework-based work projects as well as vanilla web development. To keep those two realms neatly separated I've set up a separate Vanilla profile. In that profile is a much leaner suite of extensions, configured for only vanilla web development.

  • ESLint, to automatically lint the JS code while editing
  • webhint, to lint the HTML and CSS code, and detect accessibility issues
  • html-in-template-string, to syntax highlight HTML template strings
  • Todo Tree, to keep track of TODO's while doing larger changes
  • Live Preview, to get a live preview of the page that I'm working on
  • VS Code Counter, for quick comparative line counts when porting framework code to vanilla
  • Intellicode, for simple code completion
  • Codeium, for AI-assisted code completion, works great for web component boilerplate

Modern web development can be very overburdened by tools, sometimes all but requiring the latest Macbook Pro with decadent amounts of RAM just to edit a basic project. The combination of a no build plain vanilla codebase with a lean VS Code profile guarantees quick editing, even on my oldest and slowest laptops.

Linting

Nobody's perfect, myself included, so something needs to be scanning the code for goofs. The first linting tool that's set up is ESLint. The VS Code extension regrettably does not come bundled with an eslint installation, so this has to be installed explicitly. By doing it once globally this can be reused across vanilla web projects.

npm install -g eslint @eslint/js globals

Because I use nvm to manage node versions the global eslint install was not automatically detectable. This required setting a NODE_PATH in .zshrc that VS Code then picked up.

export NODE_PATH=$(npm root -g)

In addition, in order to lint successfully it needs a configuration file, located in the project's root.

/eslint.config.cjs:

/* eslint-disable no-undef */
const globals = require("globals");
const js = require("@eslint/js");

module.exports = [
    js.configs.recommended, 
    {
        languageOptions: {
            globals: {
                ...globals.browser,
                ...globals.mocha
            },
            ecmaVersion: 2022,
            sourceType: "module",
        }
    },
    {
        ignores: [
            "public/blog/articles/",
            "**/lib/",
            "**/react/",
        ]
    }
];

Setting the ecmaVersion to 2022 ensures that I don't accidentally use newer and unsupported Javascript features, like in this example trying to use ES2024's v flag in regular expressions. This version could be set to whatever browser compatibility a project requires.

ESLint error for ECMAScript 2024 feature

The ignores blocks excludes external libraries to placate my OCD that wants to see zero errors or warnings reported by eslint project-wide. The article folders are excluded for a similar reason, because they contain a lot of incomplete and deliberately invalid example JS files.

The webhint extension is installed to do automatic linting on HTML and CSS. Luckily it out of the box comes bundled with a webhint installation and applies the default development ruleset. A nice thing about this extension is that it reports accessibility issues.

Webhint error for accessibility

Only a few tweaks were made to the webhint configuration to again get to that all-important zero warnings count.

/.hintrc:

{
  "extends": [
    "development"
  ],
  "hints": {
    "compat-api/html": [
      "default",
      {
        "ignore": [
          "iframe[loading]"
        ]
      }
    ],
    "no-inline-styles": "off"
  }
}

html-in-template-string

I've mentioned it before in the entity encoding article, but this neat little extension formats HTML inside tagged template strings in web component JS code.

Syntax highlighting in template strings

Live Preview

The center piece for a smooth editing workflow is the Live Preview extension. I can right-click on any HTML file in the project and select "Show Preview" to get a live preview of the page. This preview automatically refreshes when files are saved. Because vanilla web pages always load instantly this provides the hot-reloading workflow from framework projects, except even faster and with zero setup. The only gotcha is that all paths in the site have to be relative paths so the previewed page can resolve them.

Live Preview of this article while editing

The preview's URL can be pasted into a "real browser" to debug tricky javascript issues and do compatibility testing. Very occasionally I'll need to spin up a "real server", but most of the code in my vanilla projects is written with only a Live Preview tab open.

Previewing is also how I get a realtime view of unit tests while working on components or infrastructure code, by opening the tests web page and selecting the right test suite to hot reload while editing.

Bonus: working offline

Because I live at the beach and work in the big city I regularly need to take long train rides with spotty internet connection. Like many developers I cannot keep web API's in my head and have to look them up regularly while coding. To have a complete offline vanilla web development setup with me at all times I use devdocs.io. All my projects folders are set up to sync automatically with Syncthing, so whatever I was doing on the desktop can usually be smoothly continued offline on the laptop without having to do any prep work to make that possible.

devdocs.io screenshot

There, that's my lean vanilla web development setup. What should I add to this, or do differently? Feel free to let me know.

]]>
<![CDATA[Needs more context]]> https://plainvanillaweb.com/blog/articles/2024-10-07-needs-more-context/ 2024-10-07T12:00:00.000Z 2024-10-07T12:00:00.000Z In the earlier article the unreasonable effectiveness of vanilla JS I explained a vanilla web version of the React tutorial example Scaling up with Reducer and Context. That example used a technique for context based on Element.closest(). While that way of obtaining a context is very simple, which definitely has its merits, it also has some downsides:

  • It cannot be used from inside a shadow DOM to find a context that lives outside of it without clumsy workarounds.
  • It requires a custom element to be the context.
  • There has to be separate mechanism to subscribe to context updates.

There is in fact, or so I learned recently, a better and more standard way to solve this known as the context protocol. It's not a browser feature, but a protocol for how to implement a context in web components.

This is how it works: the consumer starts by dispatching a context-request event.

class ContextRequestEvent extends Event {
    constructor(context, callback, subscribe) {
        super('context-request', {
            bubbles: true,
            composed: true,
        });
        this.context = context;
        this.callback = callback;
        this.subscribe = subscribe;
    }
}

customElements.define('my-component', class extends HTMLElement {
    connectedCallback() {
        this.dispatchEvent(
            new ContextRequestEvent('theme', (theme) => {
                // ...
            })
        );
    }
});

The event will travel up the DOM tree (bubbles = true), piercing any shadow DOM boundaries (composed = true), until it reaches a listener that responds to it. This listener is attached to the DOM by a context provider. The context provider uses the e.context property to detect whether it should respond, then calls e.callback with the appropriate context value. Finally it calls e.stopPropagation() so the event will stop bubbling up the DOM tree.

This whole song and dance is guaranteed to happen synchronously, which enables this elegant pattern:

customElements.define('my-component', class extends HTMLElement {
    connectedCallback() {
        let theme = 'light'; // default value
        this.dispatchEvent(
            new ContextRequestEvent('theme', t => theme = t)
        );
        // do something with theme
    }
});

If no provider is registered the event's callback is never called and the default value will be used instead.

Instead of doing a one-off request for a context's value it's also possible to subscribe to updates by setting its subscribe property to true. Every time the context's value changes the callback will be called again. To ensure proper cleanup the subscribing element has to unsubscribe on disconnect.

customElements.define('my-component', class extends HTMLElement {
    #unsubscribe;
    connectedCallback() {
        this.dispatchEvent(
            new ContextRequestEvent('theme', (theme, unsubscribe) => {
                this.#unsubscribe = unsubscribe;
                // do something with theme
            }, true)
        );
    }
    disconnectedCallback() {
        this.#unsubscribe?.();
    }
});

It is recommended, but not required, to listen for and call unsubscribe functions in one-off requests, just in case a provider is overzealously creating subscriptions. However, this is not necessary when using only spec-compliant providers.

customElements.define('my-component', class extends HTMLElement {
    connectedCallback() {
        let theme = 'light';
        this.dispatchEvent(
            new ContextRequestEvent('theme', (t, unsubscribe) => {
                theme = t;
                unsubscribe?.();
            })
        );
        // do something with theme
    }
});

Providers are somewhat more involved to implement. There are several spec-compliant libraries that implement them, like @lit/context and wc-context. A very minimal implementation is this one:

export class ContextProvider extends EventTarget {
    #value;
    get value() { return this.#value }
    set value(v) { this.#value = v; this.dispatchEvent(new Event('change')); }

    #context;
    get context() { return this.#context }

    constructor(target, context, initialValue = undefined) {
        super();
        this.#context = context;
        this.#value = initialValue;
        this.handle = this.handle.bind(this);
        if (target) this.attach(target);
    }
    
    attach(target) {
        target.addEventListener('context-request', this.handle);
    }

    detach(target) {
        target.removeEventListener('context-request', this.handle);
    }

    /**
     * Handle a context-request event
     * @param {ContextRequestEvent} e 
     */
    handle(e) {
        if (e.context === this.context) {
            if (e.subscribe) {
                const unsubscribe = () => this.removeEventListener('change', update);
                const update = () => e.callback(this.value, unsubscribe);
                this.addEventListener('change', update);
                update();
            } else {
                e.callback(this.value);
            }
            e.stopPropagation();
        }
    }
}

This minimal provider can then be used in a custom element like this:

customElements.define('theme-context', class extends HTMLElement {
    themeProvider = new ContextProvider(this, 'theme', 'light');
    toggleProvider = new ContextProvider(this, 'theme-toggle', () => {
        this.themeProvider.value = this.themeProvider.value === 'light' ? 'dark' : 'light';
    });
    connectedCallback() {
        this.style.display = 'contents';
    }
});

Which would be used on a page like this, with <my-subscriber> requesting the theme by dispatching a context-request event.

<theme-context>
    <div>
        <my-subscriber></my-subscriber>
    </div>
</theme-context>

Notice in the above example that the theme-toggle context is providing a function. This unlocks a capability for dependency injection where API's to control page behavior are provided by a context to any subscribing custom element.

Don't let this example mislead you however. A provider doesn't actually need a dedicated custom element, and can be attached to any DOM node, even the body element itself. This means a context can be provided or consumed from anywhere on the page.

// loaded with <script type="module" src="theme-provider.js"></script>

import { ContextProvider } from "./context-provider.js";

const themeProvider = new ContextProvider(document.body, 'theme', 'light');
const toggleProvider = new ContextProvider(document.body, 'theme-toggle', () => {
    themeProvider.value = themeProvider.value === 'light' ? 'dark' : 'light';
});

And because there can be more than one event listener on a page, there can be more than one provider providing the same context. The first one to handle the event will win.

Here's an example that illustrates a combination of a global provider attached to the body (top panel), and a local provider using a <theme-context> (bottom panel). Every time the <theme-toggle> is reparented it resubscribes to the theme from the nearest provider.

combined example

index.js:

import { ContextRequestEvent } from "./context-request.js";
import "./theme-provider.js"; // global provider on body
import "./theme-context.js"; // element with local provider

customElements.define('theme-demo', class extends HTMLElement {
    connectedCallback() {
        this.innerHTML = `
            <theme-panel id="first">
                <theme-toggle></theme-toggle>
            </theme-panel>
            <theme-context>
                <theme-panel id="second">
                </theme-panel>
            </theme-context>
            <button>Reparent toggle</button>
        `;
        this.querySelector('button').onclick = reparent;
    }
});

customElements.define('theme-panel', class extends HTMLElement {
    #unsubscribe;

    connectedCallback() {
        this.dispatchEvent(new ContextRequestEvent('theme', (theme, unsubscribe) => {
            this.className = 'panel-' + theme;
            this.#unsubscribe = unsubscribe;
        }, true));
    }

    disconnectedCallback() {
        this.#unsubscribe?.();
    }
});

customElements.define('theme-toggle', class extends HTMLElement {
    #unsubscribe;

    connectedCallback() {
        this.innerHTML = '<button>Toggle</button>';
        this.dispatchEvent(new ContextRequestEvent('theme-toggle', (toggle) => {
            this.querySelector('button').onclick = toggle;
        }));
        this.dispatchEvent(new ContextRequestEvent('theme', (theme, unsubscribe) => {
            this.querySelector('button').className = 'button-' + theme;
            this.#unsubscribe = unsubscribe;
        }, true));
    }

    disconnectedCallback() {
        this.#unsubscribe?.();
    }
});

function reparent() {
    const toggle = document.querySelector('theme-toggle');
    const first = document.querySelector('theme-panel#first');
    const second = document.querySelector('theme-panel#second');
    if (toggle.parentNode === second) {
        first.append(toggle);
    } else {
        second.append(toggle);
    }
}

The full implementation of this protocol can be found in the tiny-context repo on Github.

]]>
<![CDATA[Lived experience]]> https://plainvanillaweb.com/blog/articles/2024-09-30-lived-experience/ 2024-09-30T12:00:00.000Z 2024-09-30T12:00:00.000Z Ryan Carniato shared a hot take a few days ago, Web Components Are Not the Future. As hot takes tend to do, it got some responses, like Nolan Lawson's piece Web components are okay, or Cory LaViska's Web Components Are Not the Future — They're the Present. They do an excellent job of directly engaging Ryan's arguments, so I'm not going to do that here. Instead I want to talk about my lived experience of web development, and where I hope it is headed in the future. Take it in the spirit it is intended, one of optimism and possibility.

A galaxy far, far away

So I've been making web sites since a long time ago, since before CSS and HTML4. I say this not to humblebrag, but to explain that I was here for all of it. Every time when the definition of modern web development changed I would update my priors, following along.

For the longest time making web pages do anything except show text and images was an exercise in frustration. Browsers were severely lacking in features, were wildly incompatible with web standards and each other, and the tools that web developers needed to bring them to order were missing or lacking. I built my share of vanilla JS components back in the IE6 days, and it made me dream of a better way. When frameworks first started coming on the scene with that better way, adding missing features, abstracting away incompatibility, and providing better tooling, I was ready for them. I was all-in.

I bought into ExtJS, loved it for a time, and then got a hundred thousand line codebase stuck on ExtJS 3 because version 4 changed things so much that porting was too costly. I then bought into Backbone, loved that too, but had to move on when its principal developer did. I joined a team that bought into AngularJS and got stuck painted into a corner when the Angular team went in a totally different direction for v2. I helped rewrite a bunch of frontends in Angular v2 and React, and found myself sucked into constant forced maintenance when their architecture and ecosystems churned.

Did I make bad choices? Even in hindsight I would say I picked the right choices for the time. Time just moved on.

The cost of change

This lived experience taught me a strong awareness of rates of change in dependencies, and the costs they impose. I imagine a web page as a thin old man sitting astride a tall pile of dependencies, each changing at their own pace. Some dependencies are stable for decades, like HTML's core set of elements, or CSS 2's set of layout primitives. They're so stable that we don't even consider them dependencies, they're just the web.

Other dependencies change every few years, like module systems, or new transpiled languages, or the preferred build and bundling tool of the day, or what framework is in vogue. Then there are the dependencies that change yearly, like major framework and OS releases. Finally there are the dependencies that change constantly, like the many packages that contribute to a typical web application built with a popular framework.

As a web developer who loves their user, taking on those dependencies creates a Solomon's choice. Either you keep up with the churn, and spend a not insignificant amount of your day working and reworking code that already works, instead of working on the things your user cares about. Or, you stick it out for as long as you can on old versions, applying ever more workarounds to get old framework releases and their outdated build and CLI tools to work in new OS and ecosystem environments, slowly boiling a frog that will at some point force a deep rewrite, again at the expense of the user.

Which is not to say the frameworks don't add value. They absolutely do, and they keep getting better. Writing new code on a new framework is a steadily rising tide of developer experience. But let us not pretend these benefits don't come at a cost. Wherever there is a codebase too complicated to understand and maintain yourself, wherever there is a set of build tools that must be kept compatible with changes in operating systems and ecosystems, there is a shelf life. Sooner or later the makers of every framework and of every tool will move on, even if it's just to a new long-term supported release, and the web developers that they served will have to move with them.

I hold this truth to be self-evident: the larger the abstraction layer a web developer uses on top of web standards, the shorter the shelf life of their codebase becomes, and the more they will feel the churn.

The rising tide

Why do modern web projects built with modern frameworks depend on so much stuff? At first there was no other option. Interacting with the DOM was painful, and web frameworks rightly made choices to keep component systems outside the DOM, minimizing and abstracting away those interactions in increasingly clever DOM reconciliation strategies. Supporting the brittle browsers and slow devices of the day required many workarounds and polyfills, and web frameworks rightly added intricate tools to build, bundle and minify the user's code.

They needed a way to bring dependencies into those build systems, and sanely settled on the convention of node modules and the NPM ecosystem. It got easy to add more dependencies, and just as water always finds the easy way down, dependencies found the easy way in. As the abstraction layer grew the load time cost imposed by it grew right along, and so we got server-side rendering, client-side hydration, lazy loading, and many other load time reduction strategies.

DOM-diffing, synthetic event systems, functional components, JSX, reactive data layers, server-side rendering and streaming, bundlers, tree shaking, transpilers and compilers, and all the other complications that you won't find in web standards but you will find in every major web framework — they are the things invented to make the modern web possible, but they are not the web. The web is what ships to the browser. And all of those things are downstream from the decision to abstract away the browser, a decision once made in good faith and for good reasons. A decision which now needs revisiting.

Browsers were not standing still. They saw what web developers were doing in userland to compensate for the deficiencies in browser API's, and they kept improving and growing the platform, a rising tide slowly catching up to what frameworks did. When Microsoft bid IE a well-deserved farewell on June 15, 2022 a tipping point was reached. For the first time the browser platform was so capable that it felt to me like it didn't need so much abstracting away anymore. It wasn't a great platform, not as cleanly designed or complete as the API's of the popular frameworks, but it was Good Enough™ as a foundation, and that was all that mattered.

Holding my breath

I was very excited for what would happen in the framework ecosystem. There was a golden opportunity for frameworks to tear down their abstraction layers, make something far simpler, far more closely aligned with the base web platform. They could have a component system built on top of web components, leveraging browser events and built-in DOM API's. All the frameworks could become cross-compatible, easily plugging into each other's data layers and components while preserving what makes them unique. The page weights would shrink by an order of magnitude with so much infrastructure code removed, and that in combination with the move to HTTP/3 could make build tools optional. It would do less, so inevitably be worse in some ways, but sometimes worse is better.

I gave a talk about how good the browser's platform had gotten, showing off a version of Create React App that didn't need any build tools and was extremely light-weight, and the developer audience was just as excited as I was. And I held my breath waiting on framework churn to for once go in the other direction, towards simplicity...

But nothing happened. In fact, the major frameworks kept building up their abstraction layers instead of building down. We got React Server Components and React Compiler, exceedingly clever, utterly incomprehensible, workarounds for self-imposed problems caused by overengineering. Web developers don't seem to mind, but they struggle quietly with how to keep up with these tools and deliver good user experiences. The bigger the abstraction layer gets, the more they feel the churn.

The irony is not lost on me that now the framework authors also feel the churn in their dependencies, struggling to adapt to web components as foundational technology. React 19 is supposed to finally support web components in a way that isn't incredibly painful, or so they say, we'll see. I confess to feeling some satisfaction in their struggle. The shoe is on the other foot. Welcome to modern web development.

The road ahead

What the frameworks are doing, that's fine for them, and they can keep doing it. But I'm done with all that unless someone is paying me to do it. They're on a fundamentally different path from where I want web development to go, from how I want to make web pages. The web is what ships to the browser. Reducing the distance between what the developer writes and what ships to the browser is valuable and necessary. This blog and this site are my own stake in the ground for this idea, showing just how much you can get done without any framework code or build tools at all. But let's be honest: web components are not a framework, no matter how hard I tried to explain them as one.

Comparing web components to React is like comparing a good bicycle with a cybertruck.

They do very different things, and they're used by different people with very, very different mindsets.

Jeremy Keith

I want a motorbike, not a cybertruck. I still want frameworks, only much lighter. Frameworks less than 10 KB in size, that are a thin layer on top of web standards but still solve the problems that frameworks solve. I call this idea the interoperable web framework:

  • Its components are just web components.
  • Its events are just DOM events.
  • Its templates are just HTML templates.
  • It doesn't need to own the DOM that its components take part in.
  • Its data and event binding works on all HTML elements, built-in or custom, made with the framework or with something else.
  • It can be easily mixed together on a page with other interoperable web frameworks, with older versions of itself, or with vanilla code.
  • It doesn't need its own build tools.

I just feel it on my bones such a thing can be built now. Maybe I'm wrong and Ryan Carniato is right. After all, he knows a lot more about frameworks than I do. But the more vanilla code that I write the more certain that I feel on this. Some existing solutions like Lit are close, but none are precisely what I am looking for. I would love to see a community of vanilla developers come together to figure out what that could look like, running experiments and iterating on the results. For now I will just keep holding my breath, waiting for the tide to come in.

]]>
<![CDATA[The unreasonable effectiveness of vanilla JS]]> https://plainvanillaweb.com/blog/articles/2024-09-28-unreasonable-effectiveness-of-vanilla-js/ 2024-09-28T12:00:00.000Z 2024-09-28T12:00:00.000Z I have a confession to make. At the end of the Plain Vanilla tutorial's Applications page a challenge was posed to the reader: port react.dev's final example Scaling Up with Reducer and Context to vanilla web code. Here's the confession: until today I had never actually ported over that example myself.

That example demonstrates a cornucopia of React's featureset. Richly interactive UI showing a tasks application, making use of a context to lift the task state up, and a reducer that the UI's controls dispatch to. React's DOM-diffing algorithm gets a real workout because each task in the list can be edited independently from and concurrently with the other tasks. It is an intricate and impressive demonstration. Here it is in its interactive glory:

complete example

But I lied. That interactive example is actually the vanilla version and it is identical. If you want to verify that it is in fact identical, check out the original React example. And with that out of the way, let's break apart the vanilla code.

Project setup

The React version has these code files that we will need to port:

  • public/index.html
  • src/styles.css
  • src/index.js: imports the styles, bootstraps React and renders the App component
  • src/App.js: renders the context's TasksProvider containing the AddTask and TaskList components
  • src/AddTask.js: renders the simple form at the top to add a new task
  • src/TaskList.js: renders the list of tasks

To make things fun, I chose the same set of files with the same filenames for the vanilla version. Here's index.html:

index.html:

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Document</title>
    <link rel="stylesheet" href="styles.css">
</head>
<body>
    <div id="root"></div>
    <script type="module" src="index.js"></script>
</body>
</html>

The only real difference is that it links to index.js and styles.css. The stylesheet was copied verbatim, but for the curious here's a link to styles.css.

Get to the code

index.js is where it starts to get interesting. Compare the React version to the vanilla version:

index.js (React):

import React, { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import "./styles.css";

import App from "./App";

const root = createRoot(document.getElementById("root"));
root.render(
  <StrictMode>
    <App />
  </StrictMode>
);

index.js (Vanilla):

import './App.js';
import './AddTask.js';
import './TaskList.js';
import './TasksContext.js';

const render = () => {
    const root = document.getElementById('root');
    root.append(document.createElement('tasks-app'));
}

document.addEventListener('DOMContentLoaded', render);

Bootstrapping is different but also similar. All of the web components are imported first to load them, and then the <tasks-app> component is rendered to the page.

The App.js code also bears more than a striking resemblance:

App.js (React):

import AddTask from './AddTask.js';
import TaskList from './TaskList.js';
import { TasksProvider } from './TasksContext.js';

export default function TaskApp() {
  return (
    <TasksProvider>
      <h1>Day off in Kyoto</h1>
      <AddTask />
      <TaskList />
    </TasksProvider>
  );
}

App.js (Vanilla):

customElements.define('tasks-app', class extends HTMLElement {
    connectedCallback() {
        this.innerHTML = `
            <tasks-context>
                <h1>Day off in Kyoto</h1>
                <task-add></task-add>
                <task-list></task-list>
            </tasks-context>
        `;
    }
});

What I like about the code so far is that it feels React-like. I generally find programming against React's API pleasing, but I don't like the tooling, page weight and overall complexity baggage that it comes with.

Adding context

The broad outline of how to bring a React-like context to a vanilla web application is already explained in the passing data deeply section of the main Plain Vanilla tutorial, so I won't cover that again here. What adds spice in this specific case is that the React context uses a reducer, a function that accepts the old tasks and an action to apply to them, and returns the new tasks to show throughout the application.

Thankfully, the React example's reducer function and initial state were already vanilla JS code, so those come along for the ride unchanged and ultimately the vanilla context is a very straightforward custom element:

TasksContext.js (Vanilla):

customElements.define('tasks-context', class extends HTMLElement {
    #tasks = structuredClone(initialTasks);
    get tasks() { return this.#tasks; }
    set tasks(tasks) {
        this.#tasks = tasks;
        this.dispatchEvent(new Event('change'));
    }

    dispatch(action) {
        this.tasks = tasksReducer(this.tasks, action);
    }

    connectedCallback() {
        this.style.display = 'contents';
    }
});

function tasksReducer(tasks, action) {
    switch (action.type) {
        case 'added': {
            return [...tasks, {
                id: action.id,
                text: action.text,
                done: false
            }];
        }
        case 'changed': {
            return tasks.map(t => {
                if (t.id === action.task.id) {
                    return action.task;
                } else {
                    return t;
                }
            });
        }
        case 'deleted': {
            return tasks.filter(t => t.id !== action.id);
        }
        default: {
            throw Error('Unknown action: ' + action.type);
        }
    }
}

const initialTasks = [
    { id: 0, text: 'Philosopher’s Path', done: true },
    { id: 1, text: 'Visit the temple', done: false },
    { id: 2, text: 'Drink matcha', done: false }
];

The actual context component is very bare bones, as it only needs to store the tasks, emit change events for the other components to subscribe to, and provide a dispatch method for those components to call that will use the reducer function to update the tasks.

Adding tasks

The AddTask component ends up offering more of a challenge. It's a stateful component with event listeners that dispatches to the reducer:

AddTask.js (React):

import { useState } from 'react';
import { useTasksDispatch } from './TasksContext.js';

export default function AddTask() {
  const [text, setText] = useState('');
  const dispatch = useTasksDispatch();
  return (
    <>
      <input
        placeholder="Add task"
        value={text}
        onChange={e => setText(e.target.value)}
      />
      <button onClick={() => {
        setText('');
        dispatch({
          type: 'added',
          id: nextId++,
          text: text,
        }); 
      }}>Add</button>
    </>
  );
}

let nextId = 3;

The main wrinkle this adds for the vanilla web component is that the event listener on the button element cannot be put inline with the markup. Luckily the handling of the input is much simplified because we can rely on it keeping its state automatically, a convenience owed to not using a virtual DOM. Thanks to the groundwork in the context component the actual dispatching of the action is easy:

AddTask.js (Vanilla):

customElements.define('task-add', class extends HTMLElement {
    connectedCallback() {
        this.innerHTML = `
            <input type="text" placeholder="Add task" />
            <button>Add</button>
        `;
        this.querySelector('button').onclick = () => {
            const input = this.querySelector('input');
            this.closest('tasks-context').dispatch({
                type: 'added',
                id: nextId++,
                text: input.value
            });
            input.value = '';
        };
    }
})

let nextId = 3;

Fascinating to me is that index.js, App.js, TasksContext.js and AddTask.js are all fewer lines of code in the vanilla version than their React counterpart while remaining functionally equivalent.

Hard mode

The TaskList component is where React starts really pulling its weight. The React version is clean and straightforward and juggles a lot of state with a constantly updating task list UI.

TaskList.js (React):

import { useState } from 'react';
import { useTasks, useTasksDispatch } from './TasksContext.js';

export default function TaskList() {
  const tasks = useTasks();
  return (
    <ul>
      {tasks.map(task => (
        <li key={task.id}>
          <Task task={task} />
        </li>
      ))}
    </ul>
  );
}

function Task({ task }) {
  const [isEditing, setIsEditing] = useState(false);
  const dispatch = useTasksDispatch();
  let taskContent;
  if (isEditing) {
    taskContent = (
      <>
        <input
          value={task.text}
          onChange={e => {
            dispatch({
              type: 'changed',
              task: {
                ...task,
                text: e.target.value
              }
            });
          }} />
        <button onClick={() => setIsEditing(false)}>
          Save
        </button>
      </>
    );
  } else {
    taskContent = (
      <>
        {task.text}
        <button onClick={() => setIsEditing(true)}>
          Edit
        </button>
      </>
    );
  }
  return (
    <label>
      <input
        type="checkbox"
        checked={task.done}
        onChange={e => {
          dispatch({
            type: 'changed',
            task: {
              ...task,
              done: e.target.checked
            }
          });
        }}
      />
      {taskContent}
      <button onClick={() => {
        dispatch({
          type: 'deleted',
          id: task.id
        });
      }}>
        Delete
      </button>
    </label>
  );
}

This proved to be a real challenge to port. The vanilla version ended up being a lot more verbose because it has to do all the same DOM-reconciliation in explicit logic managed by the update() methods of <task-list> and <task-item>.

TaskList.js (Vanilla):

customElements.define('task-list', class extends HTMLElement {
    get context() { return this.closest('tasks-context'); }
    
    connectedCallback() {
        this.context.addEventListener('change', () => this.update());
        this.append(document.createElement('ul'));
        this.update();
    }

    update() {
        const ul = this.querySelector('ul');
        let before = ul.firstChild;
        this.context.tasks.forEach(task => {
            let li = ul.querySelector(`:scope > [data-key="${task.id}"]`);
            if (!li) {
                li = document.createElement('li');
                li.dataset.key = task.id;
                li.append(document.createElement('task-item'));
            }
            li.firstChild.task = task;
            // move to the right position in the list if not there yet
            if (li !== before) ul.insertBefore(li, before);
            before = li.nextSibling;
        });
        // remove unknown nodes
        while (before) {
            const remove = before;
            before = before.nextSibling;
            ul.removeChild(remove);
        }
    }
});

customElements.define('task-item', class extends HTMLElement {
    #isEditing = false;
    #task;
    set task(task) { this.#task = task; this.update(); }
    get context() { return this.closest('tasks-context'); }

    connectedCallback() {
        if (this.querySelector('label')) return;
        this.innerHTML = `
            <label>
                <input type="checkbox" />
                <input type="text" />
                <span></span>
                <button id="edit">Edit</button>
                <button id="save">Save</button>
                <button id="delete">Delete</button>
            </label>
        `;
        this.querySelector('input[type=checkbox]').onchange = e => {
            this.context.dispatch({
                type: 'changed',
                task: {
                    ...this.#task,
                    done: e.target.checked
                }
            });
        };
        this.querySelector('input[type=text]').onchange = e => {
            this.context.dispatch({
                type: 'changed',
                task: {
                    ...this.#task,
                    text: e.target.value
                }
            });
        };
        this.querySelector('button#edit').onclick = () => {
            this.#isEditing = true;
            this.update();
        };
        this.querySelector('button#save').onclick = () => {
            this.#isEditing = false;
            this.update();
        };
        this.querySelector('button#delete').onclick = () => {
            this.context.dispatch({
                type: 'deleted',
                id: this.#task.id
            });
        };
        this.context.addEventListener('change', () => this.update());
        this.update();
    }

    update() {
        if (this.isConnected && this.#task) {
            this.querySelector('input[type=checkbox]').checked = this.#task.done;
            const inputEdit = this.querySelector('input[type=text]');
            inputEdit.style.display = this.#isEditing ? 'inline' : 'none';
            inputEdit.value = this.#task.text;
            const span = this.querySelector('span');
            span.style.display = this.#isEditing ? 'none' : 'inline';
            span.textContent = this.#task.text;
            this.querySelector('button#edit').style.display = this.#isEditing ? 'none' : 'inline';
            this.querySelector('button#save').style.display = this.#isEditing ? 'inline' : 'none';
        }
    }
});

Some interesting take-aways:

  • The <task-list> component's update() method implements a poor man's version of React reconciliation, merging the current state of the tasks array into the child nodes of the <ul>. In order to do this, it has to store a key on each list item, just like React requires, and here it becomes obvious why that is. Without the key we can't find the existing <li> nodes that match up to task items, and so would have to recreate the entire list. By adding the key it becomes possible to update the list in-place, modifying task items instead of recreating them so that they can keep their on-going edit state.
  • That reconciliation code is very generic however, and it is easy to imagine a fully generic repeat() function that converts an array of data to markup on the page. In fact, the Lit framework contains exactly that. For brevity's sake this code doesn't go quite that far.
  • The <task-item> component cannot do what the React code does: create different markup depending on the current state. Instead it creates the union of the markup across the various states, and then in the update() shows the right subset of elements based on the current state.

That wraps up the entire code. You can find the ported example on Github.

Some thoughts

A peculiar result of this porting challenge is that the vanilla version ends up being roughly the same number of lines of code as the React version. The React code is still overall less verbose (all those querySelectors, oy!), but it has its own share of boilerplate that disappears in the vanilla version. This isn't a diss against React, it's more of a compliment to how capable browsers have gotten that vanilla web components can carry us so far.

If I could have waved a magic wand, what would have made the vanilla version simpler?

  • All of those querySelector calls get annoying. The alternatives are building the markup easily with innerHTML and then fishing out references to the created elements using querySelector, or building the elements one by one verbosely using createElement, but then easily having a reference to them. Either of those ends up very verbose. An alternative templating approach that makes it easy to create elements and get a reference to them would be very welcome.
  • As long as we're dreaming, I'm jealous of how easy it is to add the event listeners in JSX. A real expression language in HTML templates that supports data and event binding and data-conditional markup would be very neat and would take away most of the reason to still find a framework's templating language more convenient. Web components are a perfectly fine alternative to React components, they just lack an easy built-in templating mechanism.
  • Browsers could get a little smarter about how they handle DOM updates during event handling. In the logic that sorts the <li> to the right order in the list, the if condition before insertBefore proved necessary because the browser didn't notice that the element was already placed where it needed to be inserted, and click events would get lost as a consequence. I've even noticed that assigning a textContent to a button mid-click will make Safari lose track of that button's click event. All of that can be worked around with clever reconciliation logic, but that's code that belongs in the browser, not in JavaScript.

All in all though, I'm really impressed with vanilla JS. I call it unreasonably effective because it is jarring just how capable the built-in abilities of browsers are, and just how many web developers despite that still default to web frameworks for every new project. Maybe one day...

]]>
<![CDATA[The life and times of a web component]]> https://plainvanillaweb.com/blog/articles/2024-09-16-life-and-times-of-a-custom-element/ 2024-09-16T12:00:00.000Z 2024-09-16T12:00:00.000Z When first taught about the wave-particle duality of light most people's brains does a double take. How can light be two different categories of things at the same time, both a wave and a particle? That's just weird. The same thing happens with web components, confusing people when they first try to learn them and run into their Document-JavaScript duality. The component systems in frameworks are typically JavaScript-first, only using the DOM as an outlet for their visual appearance. Web components however — or custom elements to be precise — can start out in either JavaScript or the document, and are married to neither.

Just the DOM please

Do you want to see the minimal JavaScript code needed to set up an <x-example> custom element? Here it is:

 

No, that's not a typo. Custom elements can be used just fine without any JavaScript. Consider this example of an <x-tooltip> custom element that is HTML and CSS only:

undefined/example.html

example.html:

<!doctype html>
<html>
    <head>
        <meta name="viewport" content="width=device-width, initial-scale=1, minimum-scale=1" />
        <link rel="stylesheet" href="example.css">
        <title>undefined custom element</title>
    </head>
    <body>
        <button>
            Hover me
            <x-tooltip inert role="tooltip">Thanks for hovering!</x-tooltip>
        </button>
    </body>
</html>

For the curious, here is the example.css, but it is not important here.

Such elements are called undefined custom elements. Before custom elements are defined in the window by calling customElements.define() they always start out in this state. There is no need to actually define the custom element if it can be solved in a pure CSS way. In fact, many "pure CSS" components found online can be solved by such custom elements, by styling the element itself and its ::before and ::after pseudo-elements.

A question of definition

The CSS-only representation of the custom element can be progressively enhanced by connecting it up to a JavaScript counterpart, a custom element class. This is a class that inherits from HTMLElement and allows the custom element to implement its own logic.

defined/example.html

example.html:

<!doctype html>
<html>
    <head>
        <meta name="viewport" content="width=device-width, initial-scale=1, minimum-scale=1" />
        <title>defining the custom element</title>
        <style>
            body { font-family: system-ui, sans-serif; margin: 1em; }
            x-example {
                background-color: lavender;
            }
            x-example:not(:defined)::after {
                content: '{defined: false}'
            }
            x-example:defined::after {
                content: '{defined: true, status: ' attr(status) '}'
            }
        </style>
    </head>
    <body>
        <p>Custom element: <x-example></x-example></p>
        <button onclick="define()">Define</button>
        <button onclick="location.reload()">Reload</button>

        <script>
            function define() {
                customElements.define('x-example', class extends HTMLElement {
                    constructor() {
                        super();
                    }
                    connectedCallback() {
                        this.setAttribute('status', 'ready');
                    }
                });
            }
        </script>
    </body>
</html>

What happens to the elements already in the markup at the moment customElements.define() is called is an element upgrade. The browser will take all custom elements already in the document, and create an instance of the matching custom element class that it connects them to. This class enables the element to control its own part of the DOM, but also allows it to react to what happens in the DOM.

Element upgrades occur for existing custom elements in the document when customElements.define() is called, and for all new custom elements with that tag name created afterwards (e.g. using document.createElement('x-example')). It does not occur automatically for detached custom elements (not part of the document) that were created before the element was defined. Those can be upgraded retroactively by calling customElements.upgrade().

So far, this is the part of the lifecycle we've seen:

<undefined> 
    -> define() -> <defined>
    -> automatic upgrade() 
                -> constructor() 
                -> <constructed>
        

The constructor as shown in the example above is optional, but if it is specified then it has a number of gotcha's:

It must start with a call to super().
It should not make DOM changes yet, as the element is not yet guaranteed to be connected to the DOM.
This includes reading or modifying its own DOM properties, like its attributes. The tricky part is that in the constructor the element might already be in the DOM, so setting attributes might work. Or it might give an error. It's best to avoid DOM interaction altogether in the constructor.
It should initialize its state, like class properties
But work done in the constructor should be minimized and maximally postponed until connectedCallback.

Making connections

After being constructed, if the element was already in the document, its connectedCallback() handler is called. This handler is normally called only when the element is inserted into the document, but for elements that are already in the document when they are defined it ends up being called as well. In this handler DOM changes can be made, and in the example above the status attribute is set to demonstrate this.

The connectedCallback() handler is part of what is known in the HTML standard as custom element reactions: These reactions allow the element to respond to various changes to the DOM:

  • connectedCallback() is called when the element is inserted into the document, even if it was only moved from a different place in the same document.
  • disconnectedCallback() is called when the element is removed from the document.
  • adoptedCallback() is called when the element is moved to a new document. (You are unlikely to need this in practice.)
  • attributeChangedCallback() is called when an attribute is changed, but only for the attributes listed in its observedAttributes property.

There are also special reactions for form-associated custom elements, but those are a rabbit hole beyond the purview of this blog post.

There are more gotcha's to these reactions:

connectedCallback() and disconnectedCallback() can be called multiple times
This can occur when the element is moved around in the document. These handlers should be written in such a way that it is harmless to run them multiple times, e.g. by doing an early exit when it is detected that connectedCallback() was already run.
attributeChangedCallback() can be called before connectedCallback()
For all attributes already set when the element in the document is upgraded, the attributeChangedCallback() handler will be called first, and only after this connectedCallback() is called. The unpleasant consequence is that any attributeChangedCallback that tries to update DOM structures created in connectedCallback can produce errors.
attributeChangedCallback() is only called for attribute changes, not property changes.
Attribute changes can be done in Javascript by calling element.setAttribute('name', 'value'). DOM attributes and class properties can have the same name, but are not automatically linked. Generally for this reason it is better to avoid having attributes and properties with the same name.

The lifecycle covered up to this point for elements that start out in the initial document:

<undefined> 
    -> define() -> <defined>
    -> automatic upgrade() 
                -> [element].constructor()
                -> [element].attributeChangedCallback()
                -> [element].connectedCallback() 
                -> <connected>
        

Flip the script

So far we've covered one half of the Document-JavaScript duality, for custom elements starting out in the document, and only after that becoming defined and gaining a JavaScript counterpart. It is however also possible to reverse the flow, and start out from JavaScript.

This is the minimal code to create a custom element in JavaScript: document.createElement('x-example'). The element does not need to be defined in order to run this code, although it can be, and the resulting node can be inserted into the document as if it was part of the original HTML markup.

If it is inserted, and after insertion the element becomes defined, then it will behave as described above. Things are however different if the element remains detached:

The detached element will not be automatically upgraded when it is defined.
The constructor or reactions will not be called. It will be automatically upgraded when it is inserted into the document. It can also be upgraded explicitly by calling customElements.upgrade().
If the detached element is already defined when it is created, it will be upgraded automatically.
The constructor() and attributeChangedCallback() will be called. Because it is not yet part of the document connectedCallback() won't be.

By now no doubt you are a bit confused. Here's an interactive playground that lets you test what happens to elements as they go through their lifecycle, both for those in the initial document and those created dynamically.

defined2/example.html

Here are some interesting things to try out:

  • Create, then Define, and you will see that the created element is not upgraded automatically because it is detached from the document.
  • Create, then Connect, then Define, and you will see that the element is upgraded automatically because it is in the document.
  • Define, then Create, and you will see that the element is upgraded as soon as it is created (constructed appears in the reactions).

I tried writing a flowchart of all possible paths through the lifecycle that can be seen in this example, but it got so unwieldy that I think it's better to just play around with the example until a solid grasp develops.

In the shadows

Adding shadow DOM creates yet another wrinkle in the lifecycle. At any point in the element's JavaScript half, including in its constructor, a shadow DOM can be attached to the element by calling attachShadow(). Because the shadow DOM is immediately available for DOM operations, that makes it possible to do those DOM operations in the constructor.

In this next interactive example you can see what happens when the shadow DOM becomes attached. The x-shadowed element will immediately attach a shadow DOM in its constructor, which happens when the element is upgraded automatically after defining. The x-shadowed-later element postpones adding a shadow DOM until a link is clicked, so the element first starts out as a non-shadowed custom element, and adds a shadow DOM later.

shadowed/example.html

example.html:

<!doctype html>
<html>
    <head>
        <meta name="viewport" content="width=device-width, initial-scale=1, minimum-scale=1" />
        <title>shadowed custom element</title>
        <style>
            body { font-family: system-ui, sans-serif; margin: 1em; }
            x-shadowed, x-shadowed-later { background-color: lightgray; }
        </style>
    </head>
    <body>
        <p>&lt;x-shadowed&gt;: <x-shadowed>undefined, not shadowed</x-shadowed></p>
        <p>&lt;x-shadowed-later&gt;: <x-shadowed-later>undefined, not shadowed</x-shadowed-later></p>
        <button id="define" onclick="define()">Define</button>
        <button onclick="location.reload()">Reload</button>

        <script>
        function define() {
            customElements.define('x-shadowed', class extends HTMLElement {
                constructor() {
                    super();
                    this.attachShadow({mode: 'open'});
                    this.shadowRoot.innerHTML = `
                        <span style="background-color: lightgreen">
                            shadowed
                        </span>
                    `;
                }
            });
            customElements.define('x-shadowed-later', class extends HTMLElement {
                connectedCallback() {
                    this.innerHTML = 'constructed, <a href="#">click to shadow</a>';
                    this.querySelector('a').onclick = (e) => { e.preventDefault(); this.addShadow() };
                }
                addShadow() {
                    this.attachShadow({mode: 'open'});
                    this.shadowRoot.innerHTML = `
                        <span style="background-color: lightgreen">
                            shadowed
                        </span>
                    `;
                }
            });
            document.querySelector('button#define').setAttribute('disabled', true);
        }
        </script>
    </body>
</html>

While adding a shadow DOM can be done at any point, it is a one-way operation. Once added the shadow DOM will replace the element's original contents, and this cannot be undone.

Keeping an eye out

So far we've mostly been dealing with initial setup of the custom element, but a major part of the lifecycle is responding to changes as they occur. Here are some of the major ways that custom elements can respond to DOM changes:

  • connectedCallback and disconnectedCallback to handle DOM insert and remove of the element itself.
  • attributeChangedCallback to handle attribute changes of the element.
  • For shadowed custom elements, the slotchange event can be used to detect when children are added and removed in a <slot>.
  • Saving the best for last, MutationObserver can be used to monitor DOM subtree changes, as well as attribute changes.

MutationObserver in particular is worth exploring, because it is a swiss army knife for monitoring the DOM. Here's an example of a counter that automatically updates when new child elements are added:

observer/example.html

example.html:

<!doctype html>
<html> 
    <head>
        <meta name="viewport" content="width=device-width, initial-scale=1, minimum-scale=1" />
        <title>custom element with observer</title>
        <style>
            body { font-family: system-ui, sans-serif; margin: 1em; }
            x-wall { display: block; margin-bottom: 1em; }
        </style>
    </head>
    <body>
        <x-wall><x-bottle></x-bottle></x-wall>
        <button onclick="add()">Add one more</button>
        <button onclick="location.reload()">Reload</button>

        <script>
        customElements.define('x-wall', class extends HTMLElement {
            connectedCallback() {
                if (this.line) return; // prevent double initialization
                this.line = document.createElement('p');
                this.insertBefore(this.line, this.firstChild);
                new MutationObserver(() => this.update()).observe(this, { childList: true });
                this.update();
            }
            update() {
                const count = this.querySelectorAll('x-bottle').length;
                this.line.textContent = 
                    `${count} ${count === 1 ? 'bottle' : 'bottles'} of beer on the wall`;
            }
        });
        customElements.define('x-bottle', class extends HTMLElement {
            connectedCallback() { this.textContent = '🍺'; }
        });
        function add() {
            document.querySelector('x-wall').append(
                document.createElement('x-bottle'));
        }
        </script>
    </body>
</html>

There is still more to tell, but already I can feel eyes glazing over and brains turning to mush, so I will keep the rest for another day.


Phew, that was a much longer story than I originally set out to write, but custom elements have surprising intricacy. I hope you found it useful, and if not at least you got to see some code and click some buttons. It's all about the clicking of the buttons.

]]>
<![CDATA[Sweet Suspense]]> https://plainvanillaweb.com/blog/articles/2024-09-09-sweet-suspense/ 2024-09-09T12:00:00.000Z 2024-09-09T12:00:00.000Z I was reading Addy Osmani and Hassan Djirdeh's book Building Large Scale Web Apps. (Which, by the way, I can definitely recommend.) In it they cover all the ways to make a React app sing at scale. The chapter on Modularity was especially interesting to me, because JavaScript modules are a common approach to modularity in both React and vanilla web code.

In that chapter on Modularity there was one particular topic that caught my eye, and it was the use of lazy() and Suspense, paired with an ErrorBoundary. These are the primitives that React gives us to asynchronously load UI components and their data on-demand while showing a fallback UI, and replace the UI with an error message when something goes wrong. If you're not familiar, here's a good overview page.

It was at that time that I was visited by the imp of the perverse, which posed to me a simple challenge: can you bring React's lazy loading primitives to vanilla web components? To be clear, there are many ways to load web components lazily. This is well-trodden territory. What wasn't out there was a straight port of lazy, suspense and error boundary. The idea would not let me go. So here goes nothing.

Lazy

The idea and execution of React's lazy is simple. Whenever you want to use a component in your code, but you don't want to actually fetch its code yet until it needs to be rendered, wrap it using the lazy() function:
const MarkdownPreview = lazy(() => import('./MarkdownPreview.js'));

React will automatically "suspend" rendering when it first bumps into this lazy component until the component has loaded, and then continue automatically.

This works in React because the markup of a component only looks like HTML, but is actually JavaScript in disguise, better known as JSX. With web components however, the markup that the component is used in is actually HTML, where there is no import() and no calling of functions. That means our vanilla lazy cannot be a JavaScript function, but instead it must be an HTML custom element:
<x-lazy><x-hello-world></x-hello-world></x-lazy>

The basic setup is simple, when the lazy component is added to the DOM, we'll scan for children that have a '-' in the name and therefore are custom elements, see if they're not yet defined, and load and define them if so. By using display: contents we can avoid having the <x-lazy> impact layout.

lazy.js:

customElements.define('x-lazy', class extends HTMLElement {
    connectedCallback() {
        this.style.display = 'contents';
        this.#loadLazy();
    }

    #loadLazy() {
        const elements = 
            [...this.children].filter(_ => _.localName.includes('-'));
        const unregistered = 
            elements.filter(_ => !customElements.get(_.localName));
        unregistered.forEach(_ => this.#loadElement(_));
    }

    #loadElement(element) {
        // TODO: load the custom element
    }
});

To actually load the element, we'll have to first find the JS file to import, and then run its register function. By having the function that calls customElements.define as the default export by convention the problem is reduced to finding the path to the JS file. The following code uses a heuristic that assumes components are in a ./components/ subfolder of the current document and follow a consistent file naming scheme:

lazy.js (continued):

    #loadElement(element) {
        // strip leading x- off the name
        const cleanName = element.localName.replace(/^x-/, '').toLowerCase();
        // assume component is in its own folder
        const url = `./components/${cleanName}/${cleanName}.js`;
        // dynamically import, then register if not yet registered
        return import(new URL(url, document.location)).then(module => 
            !customElements.get(element.localName) && module && module.default());
    }

One could get a lot more creative however, and for example use an import map to map module names to files. This I leave as an exercise for the reader.

Suspense

While the lazy component is loading, we can't show it yet. This is true for custom elements just as much as for React. That means we need a wrapper component that will show a fallback UI as long as any components in its subtree are loading, the <x-suspense> component. This starts out as a tale of two slots. When the suspense element is loading it shows the fallback, otherwise the content.

example.html:

<x-suspense>
    <p slot="fallback">Loading...</p>
    <x-lazy><x-hello-world></x-hello-world></x-lazy>
</x-suspense>

suspense.js:

export class Suspense extends HTMLElement {
    #fallbackSlot;
    #contentSlot;

    set loading(isLoading) {
        if (!this.#fallbackSlot) return;
        this.#fallbackSlot.style.display = isLoading ? 'contents' : 'none';
        this.#contentSlot.style.display = !isLoading ? 'contents' : 'none';
    }

    constructor() {
        super();
        this.attachShadow({ mode: 'open' });

        this.#fallbackSlot = document.createElement('slot');
        this.#fallbackSlot.style.display = 'none';
        this.#fallbackSlot.name = 'fallback';
        this.#contentSlot = document.createElement('slot');
        this.shadowRoot.append(this.#fallbackSlot, this.#contentSlot);
    }

    connectedCallback() {
        this.style.display = 'contents';
    }
}
customElements.define('x-suspense', Suspense);

The trick now is, how to we get loading = true to happen? In Plain Vanilla's applications page I showed how a React context can be simulated using the element.closest() API. We can use the same mechanism to create a generic API that will let our suspense wait on a promise to complete.

suspense.js (continued):

    static waitFor(sender, ...promises) {
        const suspense = sender.closest('x-suspense');
        if (suspense) suspense.addPromises(...promises);
    }

    addPromises(...promises) {
        if (!promises.length) return;
        this.loading = true;
        // combine into previous promises if there are any
        const newPromise = this.#waitingForPromise = 
            Promise.allSettled([...promises, this.#waitingForPromise]);
        // wait for all promises to complete
        newPromise.then(_ => {
            // if no newer promises were added, we're done
            if (newPromise === this.#waitingForPromise) {
                this.loading = false;
            }
        });
    }

Suspense.waitFor will call the nearest ancestor <x-suspense> to a given element, and give it a set of promises that it should wait on. This API can then be called from our <x-lazy> component. Note that #loadElement returns a promise that completes when the custom element is loaded or fails to load.

lazy.js (continued):

    #loadLazy() {
        const elements = 
            [...this.children].filter(_ => _.localName.includes('-'));
        const unregistered = 
            elements.filter(_ => !customElements.get(_.localName));
        if (unregistered.length) {
            Suspense.waitFor(this, 
                ...unregistered.map(_ => this.#loadElement(_))
            );
        }
    }

The nice thing about the promise-based approach is that we can give it any promise, just like we would with React's suspense. For example, when loading data in a custom element that is in the suspense's subtree, we can call the exact same API:
Suspense.waitFor(this, fetch(url).then(...))

Error boundary

Up to this point, we've been assuming everything always works. This is Spartasoftware, it will never "always work". What we need is a graceful way to intercept failed promises that are monitored by the suspense, and show an error message instead. That is the role that React's error boundary plays.

The approach is similar to suspense:

example.html:

<x-error-boundary>
    <p slot="error">Something went wrong</p>
    <x-suspense>
        <p slot="fallback">Loading...</p>
        <x-lazy><x-hello-world></x-hello-world></x-lazy>
    </x-suspense>
</x-error-boundary>

And the code is also quite similar to suspense:

export class ErrorBoundary extends HTMLElement {

    static showError(sender, error) {
        if (!error) throw new Error('ErrorBoundary.showError: expected two arguments but got one');
        const boundary = sender.closest('x-error-boundary');
        if (boundary) {
            boundary.error = error;
        } else {
            console.error('unable to find x-error-boundary to show error');
            console.error(error);
        }
    }

    #error;
    #errorSlot;
    #contentSlot;

    get error() {
        return this.#error;
    }

    set error(error) {
        if (!this.#errorSlot) return;
        this.#error = error;
        this.#errorSlot.style.display = error ? 'contents' : 'none';
        this.#contentSlot.style.display = !error ? 'contents' : 'none';
        if (error) {
            this.#errorSlot.assignedElements().forEach(element => {
                if (Object.hasOwn(element, 'error')) {
                    element.error = error;
                } else {
                    element.setAttribute('error', error?.message || error);
                }
            });
            this.dispatchEvent(new CustomEvent('error', { detail: error }));
        }
    }

    constructor() {
        super();
        this.attachShadow({ mode: 'open' });

        this.#errorSlot = document.createElement('slot');
        this.#errorSlot.style.display = 'none';
        this.#errorSlot.name = 'error';
        // default error message
        this.#errorSlot.textContent = 'Something went wrong.';
        this.#contentSlot = document.createElement('slot');
        this.shadowRoot.append(this.#errorSlot, this.#contentSlot);
    }

    reset() {
        this.error = null;
    }

    connectedCallback() {
        this.style.display = 'contents';
    }
}
customElements.define('x-error-boundary', ErrorBoundary);

Similar to suspense, this has an API ErrorBoundary.showError() that can be called from anywhere inside the error boundary's subtree to show an error that occurs. The suspense component is then modified to call this API when it bumps into a rejected promise. To hide the error, the reset() method can be called on the error boundary element.

Finally, the error setter will set the error as a property or attribute on all children in the error slot, which enables customizing the error message's behavior based on the error object's properties by creating a custom <x-error-message> component.

Conclusion

Finally, we can bring all of this together in a single example, that combines lazy, suspense, error boundary, a customized error message, and a lazy-loaded hello-world component.

example/index.js:

import { registerLazy } from './components/lazy.js';
import { registerSuspense } from './components/suspense.js';
import { registerErrorBoundary } from './components/error-boundary.js';
import { registerErrorMessage } from './components/error-message.js';

customElements.define('x-demo', class extends HTMLElement {

    constructor() {
        super();
        registerLazy();
        registerSuspense();
        registerErrorBoundary();
        registerErrorMessage();
    }

    connectedCallback() {
        this.innerHTML = `
            <p>Lazy loading demo</p>
            <button id="lazy-load">Load lazy</button>
            <button id="error-reset" disabled>Reset error</button>
            <div id="lazy-load-div">
                <p>Click to load..</p>
            </div>
        `;
        const resetBtn = this.querySelector('button#error-reset')
        resetBtn.onclick = () => {
            this.querySelector('x-error-boundary').reset();
            resetBtn.setAttribute('disabled', true);
        };
        const loadBtn = this.querySelector('button#lazy-load');
        loadBtn.onclick = () => {
            this.querySelector('div#lazy-load-div').innerHTML = `
                <x-error-boundary>
                    <x-error-message slot="error"></x-error-message>
                    <x-suspense>
                        <p slot="fallback">Loading...</p>
                        <p><x-lazy><x-hello-world></x-hello-world></x-lazy></p>
                    </x-suspense>
                </x-error-boundary>
            `
            this.querySelector('x-error-boundary').addEventListener('error', _ => {
                resetBtn.removeAttribute('disabled');
            });
            loadBtn.setAttribute('disabled', true);
        };
    }

});

complete example

For the complete example's code, as well as the lazy, suspense and error-boundary components, check out the sweet-suspense repo on Github.

]]>
<![CDATA[How fast are web components?]]> https://plainvanillaweb.com/blog/articles/2024-09-06-how-fast-are-web-components/ 2024-09-06T12:00:00.000Z 2024-09-15T12:00:00.000Z

Author's note

This article initially had somewhat different results and conclusions, but deficiencies in the original benchmark were pointed out and addressed. Where the conclusions were changed from the original article, this is pointed out in the text.

It is often said that web components are slow. This was also my experience when I first tried building web components a few years ago. At the time I was using the Stencil framework, because I didn't feel confident that they could be built well without a framework. Performance when putting hundreds of them on a page was so bad that I ended up going back to React.

But as I've gotten deeper into vanilla web development I started to realize that maybe I was just using it wrong. Perhaps web components can be fast, if built in a light-weight way. This article is an attempt to settle the question "How fast are web components?".

The lay of the land

What kinds of questions did I want answered?

  • How many web components can you render on the page in a millisecond?
  • Does the technique used to built the web component matter, which technique is fastest?
  • How do web component frameworks compare? I used Lit as the framework of choice as it is well-respected.
  • How does React compare?
  • What happens when you combine React with web components?

To figure out the answer I made a benchmark as a vanilla web page (of course), that renders thousands of very simple components containing only <span>.</span> and measured the elapsed time. This benchmark was then run on multiple devices and multiple browsers to figure out performance characteristics. The ultimate goal of this test is to figure out the absolute best performance that can be extracted from the most minimal web component.

To get a performance range I used two devices for testing:

  • A Macbook Air M1 running MacOS, to stand in as the "fast" device, comparable to a new high end iPhone.
  • An Asus Chi T300 Core M from 2015 running Linux Mint Cinnamon, to stand in as the "slow" device, comparable to an older low end Android.

Between these devices is a 7x CPU performance gap.

The test

The test is simple: render thousands of components using a specific technique, call requestAnimationFrame() repeatedly until they actually render, then measure elapsed time. This produces a components per millisecond number.

The techniques being compared:

  • innerHTML: each web component renders its content by assigning to this.innerHTML
  • append: each web component creates the span using document.createElement and then appends it to itself
  • append (buffered): same as the append method, except all web components are first buffered to a document fragment which is then appended to the DOM
  • shadow + innerHTML: the same as innerHTML, except each component has a shadow DOM
  • shadow + append: the same as append, except each component has a shadow DOM
  • template + append: each web component renders its content by cloning a template and appending it
  • textcontent: each web component directly sets its textContent property, instead of adding a span (making the component itself be the span)
  • direct: appends spans instead of custom elements, to be able to measure custom element overhead
  • lit: each web component is rendered using the lit framework, in the way that its documentation recommends
  • react pure: rendering in React as a standard React component, to have a baseline for comparison to mainstream web development
  • react + wc: each React component wraps the append-style web component
  • (norender): same as other strategies, except the component is only created but not added to the DOM, to separate out component construction cost

This test was run on M1 in Brave, Chrome, Edge, Firefox and Safari. And on Chi in Chrome and Firefox. It was run for 10 iterations and a geometric mean was taken of the results.

The results

First, let's compare techniques. The number here is components per millisecond, so higher is better.

Author's note: the numbers from the previous version of this article are crossed out.

Chrome on M1
techniquecomponents/ms
innerHTML143 135
append233 239
append (buffered)228 239
shadow + innerHTML132 127
shadow + append183 203
template + append181 198
textcontent345
direct461
lit133 137
react pure275 338
react + wc172 212
append (norender)1393
shadow (norender)814
direct (norender)4277
lit (norender)880
Chrome on Chi, best of three
techniquecomponents/ms
innerHTML25 29
append55 55
append (buffered)56 59
shadow + innerHTML24 26
shadow + append36 47
template + append45 46
textcontent81
direct116
lit30 33
react pure77 87
react + wc45 52
append (norender)434
shadow (norender)231
direct (norender)1290
lit (norender)239

One relief right off the bat is that even the slowest implementation on the slow device renders 100.000 components in 4 seconds. React is roughly in the same performance class as well-written web components. That means for a typical web app performance is not a reason to avoid web components.

As far as web component technique goes, the performance delta between the fastest and the slowest technique is around 2x, so again for a typical web app that difference will not matter. Things that slow down web components are shadow DOM and innerHTML. Appending directly created elements or cloned templates and avoiding shadow DOM is the right strategy for a well-performing web component that needs to end up on the page thousands of times.

On the slow device the Lit framework is a weak performer, probably due to its use of shadow DOM and JS-heavy approaches. Meanwhile, pure React is the best performer, because while it does more work in creating the virtual DOM and diffing it to the real DOM, it benefits from not having to initialize the web component class instances. Consequently, when wrapping web components inside React components we see React's performance advantage disappear, and that it adds a performance tax. In the grand scheme of things however, the differences between React and optimized web components remains small.

The fast device is up to 5x faster than the slow device in Chrome, depending on the technique used, so it is really worth testing applications on slow devices to get an idea of the range of performance.

Next, let's compare browsers:

M1, append, best of three
browsercomponents/ms
Brave146 145
Chrome233 239
Edge224 237
Firefox232 299
Safari260 239
Chi, append, best of three
browsercomponents/ms
Chrome55 55
Firefox180 77

Brave is really slow, probably because of its built-in ad blocking. Ad blocking extensions also slow down the other browsers by a lot. Safari, Chrome and Edge end up in roughly the same performance bucket. Firefox is the best performer overall. Using the "wrong" browser can halve the performance of a machine.

Author's note: due to a measurement error in measuring elapsed time, the previous version of this article had Safari as fastest and Firefox as middle of the pack.

There is a large performance gap when you compare the slowest technique on the slowest browser on the slowest device, with its fastest opposite combo. Specifically, there is a 16x performance gap:

  • textContent, Firefox on M1: 430 components/ms
  • Shadow DOM + innerHTML, Chrome on Chi: 26 components/ms

That means it becomes worthwhile to carefully consider technique when having to support a wide range of browsers and devices, because a bad combination may lead to a meaningfully degraded user experience. And of course, you should always test your web app on a slow device to make sure it still works ok.

Bottom line

I feel confident now that web components can be fast enough for almost all use cases where someone might consider React instead.

However, it does matter how they are built. Shadow DOM should not be used for smaller often used web components, and the contents of those smaller components should be built using append operations instead of innerHTML. The use of web component frameworks might impact their performance significantly, and given how easy it is to write vanilla web components I personally don't see the point behind Lit or Stencil. YMMV.

The full benchmark code and results can be found on Github.

]]>
<![CDATA[A unix philosophy for web development]]> https://plainvanillaweb.com/blog/articles/2024-09-03-unix-philosophy/ 2024-09-03T12:00:00.000Z 2024-09-03T12:00:00.000Z Web components have their malcontents. While frameworks have done their best to provide a place for web components to fit into their architecture, the suit never fits quite right, and framework authors have not been shy about expressing their disappointment. Here's Ryan Carniato of SolidJS explaining what's wrong with web components:

The collection of standards (Custom Elements, HTML Templates, Shadow DOM, and formerly HTML Imports) put together to form Web Components on the surface seem like they could be used to replace your favourite library or framework. But they are not an advanced templating solution. They don't improve your ability to render or update the DOM. They don't manage higher-level concerns for you like state management.
Ryan Carniato

While this criticism is true, perhaps it's besides the point. Maybe web components were never meant to solve those problems anyway. Maybe there are ways to solve those problems in a way that dovetails with web components as they exist. In the main components tutorial I've already explained what they can do, now let's see what can be done about the things that they can't do.

The Unix Philosophy

The Unix operating system carries with it a culture and philosophy of system design, which carries over to the command lines of today's Unix-like systems like Linux and MacOS. This philosophy can be summarized as follows:

  • Write programs that do one thing and do it well.
  • Write programs to work together.
  • Write programs to handle text streams, because that is a universal interface.

What if we look at the various technologies that comprise web components as just programs, part of a Unix-like system of web development that we collectively call the browser platform? In that system we can do better than text and use the DOM as the universal interface between programs, and we can extend the system with a set of single purpose independent "programs" (functions) that fully embrace the DOM by augmenting it instead of replacing it.

In a sense this is the most old-school way of building web projects, the one people who "don't know any better" automatically gravitate to. What us old-school web developers did before Vue and Solid and Svelte, before Angular and React, before Knockout and Ember and Backbone, before even jQuery, was have a bunch of functions in utilities.js that we copied along from project to project. But, you know, sometimes old things can become new again.

In previous posts I've already covered a html() function for vanilla entity encoding, and a signal() function that provides a tiny signals implementation that can serve as a lightweight system for state management. That still leaves a missing link between the state managed by the signals and the DOM that is rendered from safely entity-encoded HTML. What we need is a bind() function that can bind data to DOM elements and bind DOM events back to data.

Finding inspiration

In order to bind a template to data, we need a way of describing that behavior in the HTML markup. Well-trodden paths are often the best starting place to look for inspiration. I like Vue's template syntax, because it is valid HTML but just augmented, and because it is proven. Vue's templates only pretend to be HTML because they're actually compiled to JavaScript behind the scenes, but let's start there as an API. This is what it looks like:

<img :src="imageSrc" />
Bind src to track the value of the imageSrc property of the current component. Vue is smart enough to set a property if one exists, and falls back to setting an attribute otherwise. (If that confuses you, read about attributes and properties first.)
<button @click="doThis"></button>
Bind the click event to the doThis method of the current component.

By chance I came across this article about making a web component base class. In the section Declarative interactivity the author shows a way to do the Vue-like event binding syntax on a vanilla web component. This is what inspired me to develop the concept into a generic binding function and write this article.

Just an iterator

The heart of the binding function is an HTML fragment iterator. After all, before we can bind attributes we need to first find the ones that have binding directives.

export const bind = (template) => {
    const fragment = template.content.cloneNode(true);
    // iterate over all nodes in the fragment
    const iterator = document.createNodeIterator(
        fragment,
        NodeFilter.SHOW_ELEMENT,
        {
            // reject any node that is not an HTML element
            acceptNode: (node) => {
                if (!(node instanceof HTMLElement))
                    return NodeFilter.FILTER_REJECT;
                return NodeFilter.FILTER_ACCEPT;
            },
        }
    );
    let node;
    while (node = iterator.nextNode()) {
        if (!node) return;
        const elem = node;
        for (const attr of Array(...node.attributes)) {
            // check for event binding directive
            if (attr.name.startsWith('@')) {

                // TODO: bind event ...
                
                elem.removeAttributeNode(attr);
            // check for property/attribute binding directive
            } else if (attr.name.startsWith(':')) {
                
                // TODO: bind data ...
                
                elem.removeAttributeNode(attr);
            }
        }
    }
    return fragment;
}

This code will take an HTML template element, clone it to a document fragment, and then iterate over all the nodes in the fragment, discovering their attributes. Then for each attribute a check is made to see if it's a binding directive (@ or :). The node is then bound to data according to the directive attribute (shown here as TODO's), and the attribute is removed from the node. At the end the bound fragment is returned for inserting into the DOM.

The benefit of using a fragment is that it is disconnected from the main DOM, while still offering all of the DOM API's. That means we can easily create a node iterator to walk over it and discover all the attributes with binding directives, modify those nodes and attributes in-place, and still be sure we're not causing DOM updates in the main page until the fragment is inserted there. This makes the bind function very fast.

If you're thinking "woah dude, that's a lot of code and a lot of technobabble, I ain't reading all that," then please, I implore you to read through the code line by line, and you'll see it will all make sense.

Of course, we also need to have something to bind to, so we need to add a second parameter. At the same time, it would be nice to just be able to pass in a string and have it auto-converted into a template. The beginning of our bind function then ends up looking like this:

export const bind = (template, target) => {
    if (!template.content) {
        const text = template;
        template = document.createElement('template');
        template.innerHTML = text;
    }
    const fragment = template.content.cloneNode(true);
// ...
}

That just leaves us the TODO's. We can make those as simple or complicated as we want. I'll pick a middle ground.

Binding to events

This 20 line handler binds events to methods, signals or properties:

// check for custom event listener attributes
if (attr.name.startsWith('@')) {
    const event = attr.name.slice(1);
    const property = attr.value;
    let listener;
    // if we're binding the event to a function, call it directly
    if (typeof target[property] === 'function') {
        listener = target[property].bind(target);
    // if we're binding to a signal, set the signal's value
    } else if (typeof target[property] === 'object' && 
                typeof target[property].value !== 'undefined') {
        listener = e => target[property].value = e.target.value;
    // fallback: assume we're binding to a property, set the property's value
    } else {
        listener = e => target[property] = e.target.value;
    }
    elem.addEventListener(event, listener);
    // remove (non-standard) attribute from element
    elem.removeAttributeNode(attr);
}

That probably doesn't explain much, so let me give an example of what this enables:

Binding to events example

import { bind } from './bind.js';
import { signal } from './signals.js';

customElements.define('x-example', class Example extends HTMLElement {

    set a(value) { 
        this.setAttribute('a', value);
        this.querySelector('label[for=a] span').textContent = value;
    }
    set b(value) {
        this.setAttribute('b', value);
        this.querySelector('label[for=b] span').textContent = value;
    }
    c = signal('');

    connectedCallback() {
        this.append(bind(`
            <div>
                <input id="a" type="number" @input="onInputA">
                <label for="a">A = <span></span></label>
            </div>
            <div>
                <input id="b" type="number" @input="b">
                <label for="b">B = <span></span></label>
            </div>
            <div>
                <input id="c" type="number" @input="c">
                <label for="c">C = <span></span></label>
            </div>
            <button @click="onClick">Add</button>
            <div>Result: <span id="result"></span></div>
        `, this));
        this.c.effect(() => 
            this.querySelector('label[for=c] span').textContent = this.c);
    }

    onInputA (e) {
        this.a = e.target.value;
    }

    onClick() {
        this.querySelector('#result').textContent =
            +this.getAttribute('a') + +this.getAttribute('b') + +this.c;
    }
});
  • input#a's input event is handled by calling the onClickA() method.
  • input#b's input event is handled by assigning e.target.value to the b property.
  • input#c's input event is handled by setting the value of the c signal.

If you're not familiar with the signal() function, check out the tiny signals implementation in the previous post. For now you can also just roll with it.

Not a bad result for 20 lines of code.

Binding to data

Having established the pattern for events that automatically update properties, we now reverse the polarity to make data values automatically set element properties or attributes.

// ...
    if (attr.name.startsWith(':')) {
        // extract the name and value of the attribute/property
        let name = attr.name.slice(1);
        const property = getPropertyForAttribute(name, target);
        const setter = property ?
            () => elem[property] = target[attr.value] :
            () => elem.setAttribute(name, target[attr.value]);
        setter();
        // if we're binding to a signal, listen to updates
        if (target[attr.value]?.effect) {
            target[attr.value].effect(setter);
        // if we're binding to a property, listen to the target's updates
        } else if (target.addEventListener) {
            target.addEventListener('change', setter);
        }
        // remove (non-standard) attribute from element
        elem.removeAttributeNode(attr);
    }
// ...

function getPropertyForAttribute(name, obj) {
    switch (name.toLowerCase()) {
        case 'text': case 'textcontent':
            return 'textContent';
        case 'html': case 'innerhtml':
            return 'innerHTML';
        default:
            for (let prop of Object.getOwnPropertyNames(obj)) {
                if (prop.toLowerCase() === name.toLowerCase()) {
                    return prop;
                }
            }   
    }
}

The getPropertyForAttribute function is necessary because the attributes that contain the directives will have names that are case-insensitive, and these must be mapped to property names that are case-sensitive. Also, the :text and :html shorthand notations replace the role of v-text and v-html in Vue's template syntax.

When the value of the target's observed property changes, we need to update the bound element's property or attribute. This means a triggering 'change' event is needed that is then subscribed to. A framework's templating system will compare state across time, and detect the changed values automatically. Lacking such a system we need a light-weight alternative.

When the property being bound to is a signal, this code registers an effect on the signal. When the property is just a value, it registers an event listener on the target object, making it the responsibility of that target object to dispatch the 'change' event when values change. This approach isn't going to get many points for style, but it does work.

Check out the completed bind.js code.

Bringing the band together

In the article Why I don't use web components Svelte's Rich Harris lays out the case against web components. He demonstrates how this simple 9 line Svelte component <Adder a={1} b={2}/> becomes an incredible verbose 59 line monstrosity when ported to a vanilla web component.

<script>
  export let a;
  export let b;
</script>

<input type="number" bind:value={a}>
<input type="number" bind:value={b}>

<p>{a} + {b} = {a + b}</p>

Now that we have assembled our three helper functions html(), signal() and bind() on top of the web components baseline, at a total budget of around 150 lines of code, how close can we get for a web component <x-adder a="1" b="2"></x-adder>?

import { bind } from './bind.js';
import { signal, computed } from './signals.js';
import { html } from './html.js';

customElements.define('x-adder', class Adder extends HTMLElement {
    a = signal();
    b = signal();
    result = computed(() => 
        html`${+this.a} + ${+this.b} = ${+this.a + +this.b}`, [this.a, this.b]);

    connectedCallback() {
        this.a.value ??= this.getAttribute('a') || 0;
        this.b.value ??= this.getAttribute('b') || 0;
        this.append(bind(html`
            <input type="number" :value="a" @input="a" />
            <input type="number" :value="b" @input="b" />
            <p :html="result"></p>
        `, this));
    }
});

combined example

To be fair, that's still twice the lines of code, but it describes clearly what it does, and really that is all you need. And I'm just shooting in the wind here, trying stuff out. Somewhere out there could be a minimal set of functions that transforms web components into something resembling a framework, and the idea excites me! Who knows, maybe in a few years the web community will return to writing projects in vanilla web code, dragging along the modern equivalent of utilities.js from project to project...


What do you think?

]]>
<![CDATA[Poor man's signals]]> https://plainvanillaweb.com/blog/articles/2024-08-30-poor-mans-signals/ 2024-08-30T12:00:00.000Z 2024-08-30T12:00:00.000Z Signals are all the rage right now. Everyone's doing them. Angular, and Solid, and Preact, and there are third party packages for just about every framework that doesn't already have them. There's even a proposal to add them to the language, and if that passes it's just a matter of time before all frameworks have them built in.

Living under a rock

In case you've been living under a rock, here's the example from Preact's documentation that neatly summarizes what signals do:

import { signal, computed, effect } from "@preact/signals";

const name = signal("Jane");
const surname = signal("Doe");
const fullName = computed(() => `${name.value} ${surname.value}`);

// Logs name every time it changes:
effect(() => console.log(fullName.value));
// Logs: "Jane Doe"

// Updating `name` updates `fullName`, which triggers the effect again:
name.value = "John";
// Logs: "John Doe"

Simply put, signals wrap values and computations in a way that allows us to easily respond to every change to those values and results in a targeted way, without having to rerender the entire application in the way that we would do in React. In short, signals are an efficient and targeted way to respond to changes without having to do state comparison and DOM-diffing.

OK, so, if signals are so great, why am I trying to sell you on them on a vanilla web development blog? Don't worry! Vanilla web developers can have signals too.

Just a wrapper

Signals are at heart nothing more than a wrapper for a value that sends events when the value changes. That's nothing that a little trickery with the not well known but very handy EventTarget base class can't fix for us.

class Signal extends EventTarget {
    #value;
    get value () { return this.#value; }
    set value (value) {
        if (this.#value === value) return;
        this.#value = value;
        this.dispatchEvent(new CustomEvent('change')); 
    }

    constructor (value) {
        super();
        this.#value = value;
    }
}

This gets us a very barebones signals experience:

const name = new Signal('Jane');
name.addEventListener('change', () => console.log(name.value));
name.value = 'John';
// Logs: John

But that's kind of ugly. The new keyword went out of fashion a decade ago, and that addEventListener sure is unwieldy. So let's add a little syntactic sugar.

class Signal extends EventTarget {
    #value;
    get value () { return this.#value; }
    set value (value) {
        if (this.#value === value) return;
        this.#value = value;
        this.dispatchEvent(new CustomEvent('change')); 
    }

    constructor (value) {
        super();
        this.#value = value;
    }

    effect(fn) {
        fn();
        this.addEventListener('change', fn);
        return () => this.removeEventListener('change', fn);
    }

    valueOf () { return this.#value; }
    toString () { return String(this.#value); }
}

const signal = _ => new Signal(_);

Now our barebones example is a lot nicer to use:

const name = signal('Jane');
name.effect(() => console.log(name.value));
// Logs: Jane
name.value = 'John';
// Logs: John

The effect(fn) method will call the specified function, and also subscribe it to changes in the signal's value.

It also returns a dispose function that can be used to unregister the effect. However, a nice side effect of using EventTarget and browser built-in events as the reactivity primitive is that it makes the browser smart enough to garbage collect the signal and its effect when the signal goes out of scope. This means less chance for memory leaks even if we never call the dispose function.

Finally, the toString and valueOf magic methods allow for dropping .value in most places that the signal's value gets used. (But not in this example, because the console is far too clever for that.)

Does not compute

This signals implementation is already capable, but at some point it might be handy to have an effect based on more than one signal. That means supporting computed values. Where the base signals are a wrapper around a value, computed signals are a wrapper around a function.

class Computed extends Signal {
    constructor (fn, deps) {
        super(fn(...deps));
        for (const dep of deps) {
            if (dep instanceof Signal) 
                dep.addEventListener('change', () => this.value = fn(...deps));
        }
    }
}

const computed = (fn, deps) => new Computed(fn, deps);

The computed signal calculates its value from a function. It also depends on other signals, and when they change it will recompute its value. It's a bit obnoxious to have to pass the signals that it depends on as an additional parameter, but hey, I didn't title this article Rich man's signals.

This enables porting Preact's signals example to vanilla JS.

const name = signal('Jane');
const surname = signal('Doe');
const fullName = computed(() => `${name} ${surname}`, [name, surname]);
// Logs name every time it changes:
fullName.effect(() => console.log(fullName.value));
// -> Jane Doe

// Updating `name` updates `fullName`, which triggers the effect again:
name.value = 'John';
// -> John Doe

Can you use it in a sentence?

You may be thinking, all these console.log examples are fine and dandy, but how do you use this stuff in actual web development? This simple adder demonstrates how signals can be combined with web components:

import { signal, computed } from './signals.js';

customElements.define('x-adder', class extends HTMLElement {
    a = signal(1);
    b = signal(2);
    result = computed((a, b) => `${a} + ${b} = ${+a + +b}`, [this.a, this.b]);

    connectedCallback() {
        if (this.querySelector('input')) return;

        this.innerHTML = `
            <input type="number" name="a" value="${this.a}">
            <input type="number" name="b" value="${this.b}">
            <p></p>
        `;
        this.result.effect(
            () => this.querySelector('p').textContent = this.result);
        this.addEventListener('input', 
            e => this[e.target.name].value = e.target.value);
    }
});

And here's a live demo:

adder.html

In case you were wondering, the if is there to prevent adding the effect twice if connectedCallback is called when the component is already rendered.

The full poor man's signals code in all its 36 line glory can be found in the tiny-signals repo on Github.

]]>
<![CDATA[Vanilla entity encoding]]> https://plainvanillaweb.com/blog/articles/2024-08-25-vanilla-entity-encoding/ 2024-08-25T12:00:00.000Z 2024-08-25T12:00:00.000Z Good enough

When I made the first version of the Plain Vanilla website, there were things that I would have liked to spend more time on, but that I felt didn't belong in a Good Enough™ version of the site. One of those things was defending against Cross-Site Scripting (XSS).

XSS is still in the OWASP Top Ten of security issues, but it's no longer as prevalent as it used to be. Frameworks have built in a lot of defenses, and when using their templating systems you have to go out of your way to inject code into the generated HTML. When eschewing frameworks we're reduced to standard templating in our web components, and those offer no defense against XSS.

Because of this, in the original site the Passing Data example on the Components page had an undocumented XSS bug. The name field could have scripts injected into it. I felt ambivalent about leaving that bug in. On the one hand, the code was very compact and neat by leaving it in. On the other hand it made that code a bad example that shouldn't be copied. I ended up choosing to leave it as-is because an example doesn't have to be production-grade and generating properly encoded HTML was not the point of that specific example. It's time however to circle back to that XSS bug and figure out how it would have been solved in a clean and readable way, if Santa really did want to bring his List application to production-level quality.

The problem

The basic problem we need to solve is that vanilla web components end up having a lot of code that looks like this:

class MyComponent extends HTMLElement {
    connectedCallback() {
        const btn = `<button>${this.getAttribute('foo')}</button>`;
        this.innerHTML = `
            <header><h1>${this.getAttribute('bar')}</h1></header>
            <article>
                <p class="${this.getAttribute('baz')}">${this.getAttribute('xyzzy')}</p>
                ${btn}
            </article>
        `;
    }
}
customElements.define('my-component', MyComponent);

If any of foo, bar, baz or xyzzy contain one of the dangerous HTML entities, we risk seeing our component break, and worst-case risk seeing an attacker inject a malicious payload into the page. Just as a reminder, those dangerous HTML entities are <, >, &, ' and ".

The fix, take one

A naive fix is creating a html-encoding function and using it consistently:

function htmlEncode(s) {
    return s.replace(/[&<>'"]/g,
        tag => ({
            '&': '&amp;',
            '<': '&lt;',
            '>': '&gt;',
            "'": '&#39;',
            '"': '&quot;'
        }[tag]))
}

class MyComponent extends HTMLElement {
    connectedCallback() {
        const btn = `<button>${htmlEncode(this.getAttribute('foo'))}</button>`;
        this.innerHTML = `
            <header><h1>${htmlEncode(this.getAttribute('bar'))}</h1></header>
            <article>
                <p class="${htmlEncode(this.getAttribute('baz'))}">${htmlEncode(this.getAttribute('xyzzy'))}</p>
                ${btn}
            </article>
        `;
    }
}
customElements.define('my-component', MyComponent);

While this does work to defend against XSS, it is verbose and ugly, not pleasant to type and not pleasant to read. What really kills it though, is that it assumes attention to detail from us messy humans. We can never forget, never ever, to put a htmlEncode() around each and every variable. In the real world, that is somewhat unlikely.

What is needed is a solution that allows us to forget about entity encoding, by doing it automatically when we're templating. I drew inspiration from templating libraries that work in-browser and are based on tagged templates, like lit-html and htm. The quest was on to build the most minimalistic html templating function that encoded entities automatically.

The fix, take two

Ideally, the fixed example should look more like this:

import { html } from './html.js';

class MyComponent extends HTMLElement {
    connectedCallback() {
        const btn = html`<button>${this.getAttribute('foo')}</button>`;
        this.innerHTML = html`
            <header><h1>${this.getAttribute('bar')}</h1></header>
            <article>
                <p class="${this.getAttribute('baz')}">${this.getAttribute('xyzzy')}</p>
                ${btn}
            </article>
        `;
    }
}
customElements.define('my-component', MyComponent);

The html`` tagged template function would automatically encode entities, in a way that we don't even have to think about it. Even when we nest generated HTML inside of another template, like with ${btn}, it should just magically work. It would be so minimal as to disappear in the background, barely impacting readability, maybe even improving it. You may be thinking that doing that correctly would involve an impressive amount of code. I must disappoint.

class Html extends String { }

/** 
 * tag a string as html not to be encoded
 * @param {string} str
 * @returns {string}
 */
export const htmlRaw = str => new Html(str);

/** 
 * entity encode a string as html
 * @param {*} value The value to encode
 * @returns {string}
 */
export const htmlEncode = (value) => {
    // avoid double-encoding the same string
    if (value instanceof Html) {
        return value;
    } else {
        // https://stackoverflow.com/a/57448862/20980
        return htmlRaw(
            String(value).replace(/[&<>'"]/g, 
                tag => ({
                    '&': '&amp;',
                    '<': '&lt;',
                    '>': '&gt;',
                    "'": '&#39;',
                    '"': '&quot;'
                }[tag]))
        );
    }
}

/** 
 * html tagged template literal, auto-encodes entities
 */
export const html = (strings, ...values) => 
    htmlRaw(String.raw({ raw: strings }, ...values.map(htmlEncode)));

Those couple dozen lines of code are all that is needed. Let's go through it from top to bottom.

class Html extends String { }
The Html class is used to mark strings as encoded, so that they won't be encoded again.
export const htmlRaw = str => new Html(str);
Case in point, the htmlRaw function does the marking.
export const htmlEncode = ...
The earlier htmlEncode function is still doing useful work, only this time it will mark the resulting string as HTML, and it won't double-encode.
export const html = ...
The tagged template function that binds it together.

A nice upside of the html template function is that the html-in-template-string Visual Studio Code extension can detect it automatically and will syntax highlight the templated HTML. This is what example 3 looked like after I made it:

example 3 with syntax highlighting

Granted, there's still a bunch of boilerplate here, and that getAttribute gets unwieldy. But with this syntax highlighting enabled sometimes when I'm working on vanilla web components I forget it's not React and JSX, but just HTML and JS. It's surprising how nice of a development experience web standards can be if you embrace them.

I decided to leave the XSS bug in the Passing Data example, but now the Applications page has an explanation about entity encoding documenting this html template function. I can only hope people that work their way through the tutorial make it that far. For your convenience I also put the HTML templating function in its own separate html-literal repo on Github.

]]>
================================================ FILE: public/blog/generator.html ================================================ Plain Vanilla Generator

Generator

Browser support

  • Chrome: supported
  • Edge: supported
  • Safari: not supported
  • Firefox: not supported
  • Brave: supported, but first enable File System Access in brave://flags
  • Copy to clipboard: only over HTTPS
================================================ FILE: public/blog/generator.js ================================================ import { html } from '../lib/html.js'; const BLOG_BASE_URL = 'https://plainvanillaweb.com/blog/'; const ATOM_FEED_XML = ` Plain Vanilla Blog https://plainvanillaweb.com/blog/ https://plainvanillaweb.com/favicon.ico https://plainvanillaweb.com/android-chrome-512x512.png %UPDATED% Joeri Sebrechts %ENTRIES% `; const ATOM_FEED_LENGTH = 20; customElements.define('blog-generator', class BlogGenerator extends HTMLElement { #blogFolder; #articles; // [{ slug, title, summary, content, published, image: { src, alt } }] reset() { this.#blogFolder = null; this.#articles = []; this.textContent = 'To start: drag the blog folder here, or click to open it with a picker.'; } showError(text) { this.textContent = ''; this.addMessage(text, 'warning'); } connectedCallback() { this.reset(); this.addDragListeners(); this.addClickListener(); } addClickListener() { this.addEventListener('click', () => { if (this.#blogFolder) return; if (!window.showDirectoryPicker) { this.showError('Opening folders with a picker is not supported in your browser.'); } else { window.showDirectoryPicker({ id: 'plain-vanilla-generator', mode: 'read', startIn: 'documents' }).then(async (entry) => { await this.startProcessing(entry); }).catch((e) => { this.showError(e.message); }); } }); } addDragListeners() { this.addEventListener('dragover', (e) => { // Prevent navigation. e.preventDefault(); }); this.addEventListener('drop', async (e) => { try { // Prevent navigation. e.preventDefault(); // Process all of the items. const item = e.dataTransfer.items[0]; // Careful: `kind` will be 'file' for both file _and_ directory entries. if (item.kind === 'file') { if (!item.getAsFileSystemHandle) { throw new Error('Dropping files is not supported in your browser.'); } await this.startProcessing(item.getAsFileSystemHandle()); } } catch (e) { this.showError(e.message); } }); } async startProcessing(blogFolder) { this.#blogFolder = await blogFolder; if (this.#blogFolder.kind !== 'directory' || this.#blogFolder.name !== 'blog') { this.#blogFolder = null; throw new Error('That folder is not the blog directory.'); } this.#articles = []; this.innerHTML = ''; this.addMessage('Processing...'); const articlesFolder = await this.#blogFolder.getDirectoryHandle('articles'); for await (const [key, value] of articlesFolder.entries()) { if (value.kind === 'directory') { this.addMessage(`Parsing ${key}/`); try { const article = await value.getFileHandle('index.html'); await this.processArticle(article, value); } catch (e) { if (e.name === 'NotFoundError') { this.addMessage(`Folder ${key}/ does not have an index.html file, skipping.`, 'warning'); continue; } throw new Error('Error processing ' + value.name + ': ' + e.message); } } } // sort articles by published descending this.#articles.sort((a, b) => { return -a.published.localeCompare(b.published); }); this.addFeedBlock(); this.addIndexJsonBlock(); this.addSitemapBlock(); } async processArticle(article, path) { const file = await article.getFile(); const html = await file.text(); const dom = (new DOMParser()).parseFromString(html, 'text/html'); // mandatory const title = dom.querySelector('title').textContent; const summary = dom.querySelector('meta[name="description"]').getAttribute('content'); const published = dom.querySelector('blog-header').getAttribute('published'); const content = await this.processArticleContent(dom.querySelector('main'), path); const slug = path.name; // optional const img = dom.querySelector('blog-header img'); const image = img && { src: img.getAttribute('src'), alt: img.getAttribute('alt') }; const updated = dom.querySelector('blog-header').getAttribute('updated') || undefined; const author = dom.querySelector('blog-header p[aria-label="author"]').textContent || undefined; this.#articles.push({ slug, title, summary, content, published, updated, image, author }); } async processArticleContent(main, path) { // inline code examples await Promise.all([...main.querySelectorAll('x-code-viewer')].map(async (elem) => { const text = await this.downloadFile(elem.getAttribute('src'), path); const div = document.createElement('div'); const name = elem.getAttribute('name'); const label = name ? html`

${name}:

` : ''; div.innerHTML = html`${label}
${text}
`; elem.replaceWith(div); })); // convert img src to absolute url [...main.querySelectorAll('img')].map((elem) => { const src = elem.getAttribute('src'); if (src.indexOf('http') !== 0) { elem.setAttribute('src', new URL(`articles/${path.name}/${src}`, BLOG_BASE_URL)); } }); // replace iframes by links [...main.querySelectorAll('iframe')].map((elem) => { const src = elem.getAttribute('src'); const title = elem.getAttribute('title') || src; const a = document.createElement('a'); a.textContent = title; const p = document.createElement('p'); p.appendChild(a); elem.replaceWith(p); if (src.indexOf('http') !== 0) { a.href = new URL(`articles/${path.name}/${src}`, BLOG_BASE_URL); } else { a.href = src; } }); // strip out unwanted elements [...main.querySelectorAll('[data-rss-exclude]')].map((elem) => elem.remove()); return main.innerHTML; } async downloadFile(file, path) { const parts = await this.#blogFolder.resolve(path); parts.push(file); const url = new URL(parts.join('/'), import.meta.url); return fetch(url).then(res => res.text()); } addMessage(text, className) { const message = document.createElement('p'); message.textContent = text; message.className = className; this.appendChild(message); } addFeedBlock() { const lastUpdated = this.#articles.map(a => a.updated || a.published).sort().pop(); const xml = ATOM_FEED_XML .replace('%UPDATED%', toISODate(lastUpdated)) .replace('%ENTRIES%', this.#articles.slice(0, ATOM_FEED_LENGTH).map(a => { const url = `${BLOG_BASE_URL}articles/${a.slug}/`; const media = a.image ? `` : ''; const author = a.author ? `` : ''; return ` <![CDATA[${a.title}]]> ${url} ${toISODate(a.published)} ${toISODate(a.updated || a.published)} ${media} ${author} `; }).join('\n')); this.addMessage('feed.xml:'); const pre = document.createElement('pre'); pre.textContent = xml; this.appendChild(pre); pre.scrollIntoView(); const button = document.createElement('button'); button.onclick = () => navigator.clipboard.writeText(xml); button.textContent = 'Copy feed.xml to clipboard'; this.appendChild(button); this.addMessage(' '); } addIndexJsonBlock() { const text = JSON.stringify(this.#articles.map(obj => ({ ...obj, content: undefined, image: undefined })), null, 4); this.addMessage('articles/index.json:'); const pre = document.createElement('pre'); pre.textContent = text; this.appendChild(pre); pre.scrollIntoView(); const button = document.createElement('button'); button.onclick = () => navigator.clipboard.writeText(text); button.textContent = 'Copy index.json to clipboard'; this.appendChild(button); } addSitemapBlock() { const sitemap = this.#articles.map(a => `${BLOG_BASE_URL}articles/${a.slug}/index.html`).sort().join('\n'); this.addMessage('sitemap.txt:'); const pre = document.createElement('pre'); pre.textContent = sitemap; this.appendChild(pre); const button = document.createElement('button'); button.onclick = () => navigator.clipboard.writeText(sitemap); button.textContent = 'Copy sitemap.txt to clipboard'; this.appendChild(button); } }); function toISODate(date) { if (typeof date === 'string') { // default to publishing at noon UTC if (date.indexOf('T') === -1) { date = date + 'T12:00:00.000Z'; } return new Date(date).toISOString(); } return date; } ================================================ FILE: public/blog/index.css ================================================ @import "../index.css"; .cards { padding: 0; display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); grid-gap: 2em; row-gap: 0; grid-auto-flow: row; grid-auto-rows: minmax(100px, auto); } .card { display: block; position: relative; list-style: none; padding: 1em; margin: 0 -1em; font-size: 0.9em; border-radius: 5px; container-type: inline-size; container-name: card; } .card img { width: 100%; height: 120px; object-fit: cover; margin-bottom: 0.5em; } .card h3 { margin-top: 0; margin-bottom: 0.5em; } .card a { text-decoration: none; color: inherit; } /* make the whole card focusable */ .card:focus-within { box-shadow: 0 0 0 0.25rem; } .card:focus-within a:focus { text-decoration: none; } /* turn the whole card into the clickable area */ .card h3 a::after { display: block; position: absolute; content: ""; top: 0; left: 0; right: 0; bottom: 0; } /* make byline links clickable */ .card small { position: relative; z-index: 10; color: var(--text-color-mute); } .card small a { text-decoration: underline; } .card small a:hover { color: var(--text-color); } /* for hero cards (full width), move the image to the left */ @container card ( min-width: 500px ) { .card img { float: left; width: calc(50% - 1em); height: 200px; margin-right: 2em; margin-bottom: 0; } } .archive { text-align: center; color: var(--text-color-mute); a:not(:hover) { color: inherit; } } .byline { color: var(--text-color-mute); font-size: 0.8em; margin-bottom: 1.5em; } main img { margin: 0.5em 0; } .comments { margin-top: 2em; text-align: center; } /* header section */ blog-header { display: block; margin-bottom: 1.5em; text-align: left; } blog-header nav { margin-bottom: 2em; } blog-header img { margin-top: 0.5em; } @media screen and (max-width: 600px) { h1 { font-size: 2.2em; } } @media (scripting: none) { blog-header::before { content: 'Please enable scripting to view the navigation' } } ================================================ FILE: public/blog/index.html ================================================ Plain Vanilla Blog

Plain Vanilla Blog

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

Featured

Latest Posts

Archive | RSS

================================================ FILE: public/blog/index.js ================================================ import { registerBlogFooter } from "./components/blog-footer.js"; import { registerBlogHeader } from "./components/blog-header.js"; import { registerBlogLatestPosts } from "./components/blog-latest-posts.js"; import { registerBlogArchive } from "./components/blog-archive.js"; import { registerCodeViewerComponent } from "../components/code-viewer/code-viewer.js"; const app = async () => { registerBlogLatestPosts(); registerBlogHeader(); registerBlogFooter(); registerBlogArchive(); registerCodeViewerComponent(); const { registerAnalyticsComponent } = await import("../components/analytics/analytics.js"); registerAnalyticsComponent(); } document.addEventListener('DOMContentLoaded', app); ================================================ FILE: public/components/analytics/analytics.js ================================================ class AnalyticsComponent extends HTMLElement { #template; constructor() { super(); fetch(import.meta.resolve('../../analytics.template')) .then(res => res.ok && res.text()) .then(template => { this.#template = template; this.update(); }) .catch(e => console.error(e)); } connectedCallback() { this.update(); } update() { if (this.isConnected && this.#template) { this.innerHTML = this.#template; // replace scripts by executable versions const scripts = this.getElementsByTagName('script'); for (const script of scripts) { const newScript = document.createElement('script'); for (const attr of script.attributes) { newScript.setAttribute(attr.name, attr.value); } newScript.innerHTML = script.innerHTML; script.replaceWith(newScript); } } } } export const registerAnalyticsComponent = () => { customElements.define('x-analytics', AnalyticsComponent); } ================================================ FILE: public/components/code-viewer/code-viewer.css ================================================ @import "../../lib/speed-highlight/themes/github-dark.css"; x-code-viewer { display: block; display: flex; flex-direction: column; } x-code-viewer label, x-code-viewer code { display: block; font-family: var(--font-system-code); font-size: var(--font-system-code-size); white-space: pre; padding: 1em; } x-code-viewer label { flex: 0 0 auto; border-bottom: 1px dotted var(--border-color); } x-code-viewer label:empty { display: none; } x-code-viewer code { position: relative; flex: 1 1 auto; overflow: auto; min-height: 8em; } x-code-viewer.loading code::after { content: ''; box-sizing: border-box; width: 30px; height: 30px; position: absolute; top: calc(50% - 15px); left: calc(50% - 15px); border-radius: 50%; border-top: 4px solid ghostwhite; border-left: 4px solid ghostwhite; border-right: 4px solid ghostwhite; animation: code-viewer-spinner .6s linear infinite; } @keyframes code-viewer-spinner { to {transform: rotate(360deg);} } @media (scripting: none) { x-code-viewer::before { content: 'Enable scripting to view ' attr(src) } } ================================================ FILE: public/components/code-viewer/code-viewer.js ================================================ import { highlightElement } from "../../lib/speed-highlight/index.js"; /** * Code Viewer component * * Usage: * - show code with label "code.js" * - show code with label "My Code" */ class CodeViewer extends HTMLElement { connectedCallback() { this.innerHTML = ` `; // load code (and name) from src attribute const src = this.getAttribute('src'); if (src) { if (!this.hasAttribute('name')) { this.setAttribute('name', src.split('/').pop()); } this.classList.add('loading'); fetch(src).then(res => res.text()).then(text => { this.setAttribute('code', text); }).catch((e) => this.setAttribute('code', e.message)) .finally(() => this.classList.remove('loading')); } this.update(); } static get observedAttributes() { return ['code', 'name']; } attributeChangedCallback() { this.update(); } update() { const label = this.querySelector('label'); const code = this.querySelector('code'); if (label && code) { label.textContent = this.getAttribute('name'); code.textContent = this.getAttribute('code'); // should we syntax highlight? const src = this.getAttribute('src') || ''; const lang = src.split('.').pop(); if (['html', 'js', 'css'].includes(lang)) { code.className = 'shj-lang-' + lang; } else { code.className = 'shj-lang-plain'; } highlightElement(code); } } } export const registerCodeViewerComponent = () => customElements.define('x-code-viewer', CodeViewer); ================================================ FILE: public/components/tab-panel/tab-panel.css ================================================ x-tab-panel { display: block; border: 1px solid var(--border-color); } x-tab-panel div[role=tablist] { display: block; border-bottom: 1px dotted var(--border-color); } x-tab-panel div[role=tablist] button[role=tab] { font-family: var(--font-system); font-size: 100%; color: inherit; background-color: transparent; background-image: none; border: none; padding: 0.5em; padding-top: 0.6em; margin-left: 0.5em; border-bottom: 2px solid transparent; } x-tab-panel div[role=tablist] button[role=tab][aria-selected=true] { font-weight: bold; border-bottom: 2px solid var(--border-color); } x-tab-panel > x-tab { display: none; } x-tab-panel > x-tab[active] { display: block !important; } ================================================ FILE: public/components/tab-panel/tab-panel.js ================================================ /** * Tabbed panel component * * Usage: * * *

Tab 1 content

*
* *

Tab 2 content

*
*
* * See: https://www.w3.org/WAI/ARIA/apg/patterns/tabs/ */ class TabPanel extends HTMLElement { #tablist; #observer; get tablist() { return this.#tablist; } get tabpanels() { return this.querySelectorAll('x-tab'); } constructor() { super(); this.#observer = new MutationObserver(this.onMutation.bind(this)); this.#observer.observe(this, { childList: true }); } connectedCallback() { this.#tablist = document.createElement('div'); this.#tablist.setAttribute('role', 'tablist'); this.insertBefore(this.#tablist, this.firstChild); } onMutation(m) { if (m.filter(_ => _.type === 'childList').length) { this.tablist.innerHTML = ''; this.tabpanels.forEach(tabPanel => { if (tabPanel.role !== 'tabpanel') { tabPanel.style.display = 'none'; } else { const tab = document.createElement('button'); tab.setAttribute('role', 'tab'); tab.setAttribute('aria-controls', tabPanel.id); tab.textContent = tabPanel.title; tab.onclick = () => this.activatePanel(tabPanel.id); this.tablist.appendChild(tab); } }); } this.update(); } activatePanel(id) { this.tabpanels.forEach(tabPanel => { if (tabPanel.id === id) { tabPanel.setAttribute('active', 'true'); } else { tabPanel.removeAttribute('active'); } }); this.update(); } update() { this.tabpanels.forEach(tabPanel => { const tab = this.tablist.querySelector(`[aria-controls="${tabPanel.id}"]`); if (tab) tab.setAttribute('aria-selected', tabPanel.hasAttribute('active')); }) } } class Tab extends HTMLElement { static #nextId = 0; connectedCallback() { this.role = 'tabpanel'; this.id = 'tab-panel-' + Tab.#nextId++; } } export const registerTabPanelComponent = () => { customElements.define('x-tab-panel', TabPanel); customElements.define('x-tab', Tab); } ================================================ FILE: public/index.css ================================================ @import "./styles/reset.css"; @import "./styles/variables.css"; @import "./styles/global.css"; @import "./components/code-viewer/code-viewer.css"; @import "./components/tab-panel/tab-panel.css"; ================================================ FILE: public/index.html ================================================ Plain Vanilla Skip to main content

Plain Vanilla

An explainer for web development using only vanilla techniques.
No tools, no frameworks — just HTML, CSS, and JavaScript.

Up next


Learn how to use Web Components to compose content, style and behavior.

Get Started

================================================ FILE: public/index.js ================================================ import { registerCodeViewerComponent } from "./components/code-viewer/code-viewer.js"; import { registerTabPanelComponent } from "./components/tab-panel/tab-panel.js"; const app = async () => { registerCodeViewerComponent(); registerTabPanelComponent(); const { registerAnalyticsComponent } = await import("./components/analytics/analytics.js"); registerAnalyticsComponent(); } document.addEventListener('DOMContentLoaded', app); ================================================ FILE: public/lib/html.js ================================================ class Html extends String { } /** * tag a string as html not to be encoded * @param {string} str * @returns {string} */ export const htmlRaw = str => new Html(str); /** * entity encode a string as html * @param {*} value The value to encode * @returns {string} */ export const htmlEncode = (value) => { // avoid double-encoding the same string if (value instanceof Html) { return value; } else { // https://stackoverflow.com/a/57448862/20980 return htmlRaw( String(value).replace(/[&<>'"]/g, tag => ({ '&': '&', '<': '<', '>': '>', "'": ''', '"': '"' }[tag])) ); } } /** * html tagged template literal, auto-encodes entities */ export const html = (strings, ...values) => htmlRaw(String.raw({ raw: strings }, ...values.map(htmlEncode))); ================================================ FILE: public/lib/speed-highlight/LICENSE ================================================ Creative Commons Legal Code CC0 1.0 Universal CREATIVE COMMONS CORPORATION IS NOT A LAW FIRM AND DOES NOT PROVIDE LEGAL SERVICES. DISTRIBUTION OF THIS DOCUMENT DOES NOT CREATE AN ATTORNEY-CLIENT RELATIONSHIP. CREATIVE COMMONS PROVIDES THIS INFORMATION ON AN "AS-IS" BASIS. CREATIVE COMMONS MAKES NO WARRANTIES REGARDING THE USE OF THIS DOCUMENT OR THE INFORMATION OR WORKS PROVIDED HEREUNDER, AND DISCLAIMS LIABILITY FOR DAMAGES RESULTING FROM THE USE OF THIS DOCUMENT OR THE INFORMATION OR WORKS PROVIDED HEREUNDER. Statement of Purpose The laws of most jurisdictions throughout the world automatically confer exclusive Copyright and Related Rights (defined below) upon the creator and subsequent owner(s) (each and all, an "owner") of an original work of authorship and/or a database (each, a "Work"). Certain owners wish to permanently relinquish those rights to a Work for the purpose of contributing to a commons of creative, cultural and scientific works ("Commons") that the public can reliably and without fear of later claims of infringement build upon, modify, incorporate in other works, reuse and redistribute as freely as possible in any form whatsoever and for any purposes, including without limitation commercial purposes. These owners may contribute to the Commons to promote the ideal of a free culture and the further production of creative, cultural and scientific works, or to gain reputation or greater distribution for their Work in part through the use and efforts of others. For these and/or other purposes and motivations, and without any expectation of additional consideration or compensation, the person associating CC0 with a Work (the "Affirmer"), to the extent that he or she is an owner of Copyright and Related Rights in the Work, voluntarily elects to apply CC0 to the Work and publicly distribute the Work under its terms, with knowledge of his or her Copyright and Related Rights in the Work and the meaning and intended legal effect of CC0 on those rights. 1. Copyright and Related Rights. A Work made available under CC0 may be protected by copyright and related or neighboring rights ("Copyright and Related Rights"). Copyright and Related Rights include, but are not limited to, the following: i. the right to reproduce, adapt, distribute, perform, display, communicate, and translate a Work; ii. moral rights retained by the original author(s) and/or performer(s); iii. publicity and privacy rights pertaining to a person's image or likeness depicted in a Work; iv. rights protecting against unfair competition in regards to a Work, subject to the limitations in paragraph 4(a), below; v. rights protecting the extraction, dissemination, use and reuse of data in a Work; vi. database rights (such as those arising under Directive 96/9/EC of the European Parliament and of the Council of 11 March 1996 on the legal protection of databases, and under any national implementation thereof, including any amended or successor version of such directive); and vii. other similar, equivalent or corresponding rights throughout the world based on applicable law or treaty, and any national implementations thereof. 2. Waiver. To the greatest extent permitted by, but not in contravention of, applicable law, Affirmer hereby overtly, fully, permanently, irrevocably and unconditionally waives, abandons, and surrenders all of Affirmer's Copyright and Related Rights and associated claims and causes of action, whether now known or unknown (including existing as well as future claims and causes of action), in the Work (i) in all territories worldwide, (ii) for the maximum duration provided by applicable law or treaty (including future time extensions), (iii) in any current or future medium and for any number of copies, and (iv) for any purpose whatsoever, including without limitation commercial, advertising or promotional purposes (the "Waiver"). Affirmer makes the Waiver for the benefit of each member of the public at large and to the detriment of Affirmer's heirs and successors, fully intending that such Waiver shall not be subject to revocation, rescission, cancellation, termination, or any other legal or equitable action to disrupt the quiet enjoyment of the Work by the public as contemplated by Affirmer's express Statement of Purpose. 3. Public License Fallback. Should any part of the Waiver for any reason be judged legally invalid or ineffective under applicable law, then the Waiver shall be preserved to the maximum extent permitted taking into account Affirmer's express Statement of Purpose. In addition, to the extent the Waiver is so judged Affirmer hereby grants to each affected person a royalty-free, non transferable, non sublicensable, non exclusive, irrevocable and unconditional license to exercise Affirmer's Copyright and Related Rights in the Work (i) in all territories worldwide, (ii) for the maximum duration provided by applicable law or treaty (including future time extensions), (iii) in any current or future medium and for any number of copies, and (iv) for any purpose whatsoever, including without limitation commercial, advertising or promotional purposes (the "License"). The License shall be deemed effective as of the date CC0 was applied by Affirmer to the Work. Should any part of the License for any reason be judged legally invalid or ineffective under applicable law, such partial invalidity or ineffectiveness shall not invalidate the remainder of the License, and in such case Affirmer hereby affirms that he or she will not (i) exercise any of his or her remaining Copyright and Related Rights in the Work or (ii) assert any associated claims and causes of action with respect to the Work, in either case contrary to Affirmer's express Statement of Purpose. 4. Limitations and Disclaimers. a. No trademark or patent rights held by Affirmer are waived, abandoned, surrendered, licensed or otherwise affected by this document. b. Affirmer offers the Work as-is and makes no representations or warranties of any kind concerning the Work, express, implied, statutory or otherwise, including without limitation warranties of title, merchantability, fitness for a particular purpose, non infringement, or the absence of latent or other defects, accuracy, or the present or absence of errors, whether or not discoverable, all to the greatest extent permissible under applicable law. c. Affirmer disclaims responsibility for clearing rights of other persons that may apply to the Work or any use thereof, including without limitation any person's Copyright and Related Rights in the Work. Further, Affirmer disclaims responsibility for obtaining any necessary consents, permissions or other rights required for any use of the Work. d. Affirmer understands and acknowledges that Creative Commons is not a party to this document and has no duty or obligation with respect to this CC0 or use of the Work. ================================================ FILE: public/lib/speed-highlight/common.js ================================================ /** * Commonly used match pattern */ export default { num: { type: 'num', match: /(\.e?|\b)\d(e-|[\d.oxa-fA-F_])*(\.|\b)/g }, str: { type: 'str', match: /(["'])(\\[^]|(?!\1)[^\r\n\\])*\1?/g }, strDouble: { type: 'str', match: /"((?!")[^\r\n\\]|\\[^])*"?/g } } ================================================ FILE: public/lib/speed-highlight/index.js ================================================ /** * @module index * (Base script) */ /** * Default languages supported * @typedef {('asm'|'bash'|'bf'|'c'|'css'|'csv'|'diff'|'docker'|'git'|'go'|'html'|'http'|'ini'|'java'|'js'|'jsdoc'|'json'|'leanpub-md'|'log'|'lua'|'make'|'md'|'pl'|'plain'|'py'|'regex'|'rs'|'sql'|'todo'|'toml'|'ts'|'uri'|'xml'|'yaml')} ShjLanguage */ /** * Themes supported in the browser * @typedef {('atom-dark'|'github-dark'|'github-dim'|'dark'|'default'|'github-light'|'visual-studio-dark')} ShjBrowserTheme */ /** * @typedef {Object} ShjOptions * @property {Boolean} [hideLineNumbers=false] Indicates whether to hide line numbers */ /** * @typedef {('inline'|'oneline'|'multiline')} ShjDisplayMode * * `inline` inside `code` element * * `oneline` inside `div` element and containing only one line * * `multiline` inside `div` element */ /** * Token types * @typedef {('deleted'|'err'|'var'|'section'|'kwd'|'class'|'cmnt'|'insert'|'type'|'func'|'bool'|'num'|'oper'|'str'|'esc')} ShjToken */ import expandData from './common.js'; const langs = {}, sanitize = (str = '') => str.replaceAll('&', '&').replaceAll?.('<', '<').replaceAll?.('>', '>'), /** * Create a HTML element with the right token styling * * @function * @ignore * @param {string} str The content (need to be sanitized) * @param {ShjToken} [token] The type of token * @returns A HMTL string */ toSpan = (str, token) => token ? `${str}` : str; /** * Find the tokens in the given code and call the given callback * * @function tokenize * @param {string} src The code * @param {ShjLanguage|Array} lang The language of the code * @param {function(string, ShjToken=):void} token The callback function * this function will be given * * the text of the token * * the type of the token */ export async function tokenize(src, lang, token) { try { let m, part, first = {}, match, cache = [], i = 0, data = typeof lang === 'string' ? (await (langs[lang] ??= import(`./languages/${lang}.js`))) : lang, // make a fast shallow copy to bee able to splice lang without change the original one arr = [...typeof lang === 'string' ? data.default : lang.sub]; while (i < src.length) { first.index = null; for (m = arr.length; m-- > 0;) { part = arr[m].expand ? expandData[arr[m].expand] : arr[m]; // do not call again exec if the previous result is sufficient if (cache[m] === undefined || cache[m].match.index < i) { part.match.lastIndex = i; match = part.match.exec(src); if (match === null) { // no more match with this regex can be disposed arr.splice(m, 1); cache.splice(m, 1); continue; } // save match for later use to decrease performance cost cache[m] = { match, lastIndex: part.match.lastIndex }; } // check if it the first match in the string if (cache[m].match[0] && (cache[m].match.index <= first.index || first.index === null)) first = { part: part, index: cache[m].match.index, match: cache[m].match[0], end: cache[m].lastIndex } } if (first.index === null) break; token(src.slice(i, first.index), data.type); i = first.end; if (first.part.sub) await tokenize(first.match, typeof first.part.sub === 'string' ? first.part.sub : (typeof first.part.sub === 'function' ? first.part.sub(first.match) : first.part), token); else token(first.match, first.part.type); } token(src.slice(i, src.length), data.type); } catch { token(src); } } /** * Highlight a string passed as argument and return it * @example * elm.innerHTML = await highlightText(code, 'js'); * * @async * @function highlightText * @param {string} src The code * @param {ShjLanguage} lang The language of the code * @param {Boolean} [multiline=true] If it is multiline, it will add a wrapper for the line numbering and header * @param {ShjOptions} [opt={}] Customization options * @returns {Promise} The highlighted string */ export async function highlightText(src, lang, multiline = true, opt = {}) { let tmp = '' await tokenize(src, lang, (str, type) => tmp += toSpan(sanitize(str), type)) return multiline ? `
${'
'.repeat(!opt.hideLineNumbers && src.split('\n').length)}
${tmp}
` : tmp; } /** * Highlight a DOM element by getting the new innerHTML with highlightText * * @async * @function highlightElement * @param {Element} elm The DOM element * @param {ShjLanguage} [lang] The language of the code (seaching by default on `elm` for a 'shj-lang-' class) * @param {ShjDisplayMode} [mode] The display mode (guessed by default) * @param {ShjOptions} [opt={}] Customization options */ export async function highlightElement(elm, lang = elm.className.match(/shj-lang-([\w-]+)/)?.[1], mode, opt) { let txt = elm.textContent; mode ??= `${elm.tagName == 'CODE' ? 'in' : (txt.split('\n').length < 2 ? 'one' : 'multi')}line`; elm.dataset.lang = lang; elm.className = `${[...elm.classList].filter(className => !className.startsWith('shj-')).join(' ')} shj-lang-${lang} shj-${mode}`; elm.innerHTML = await highlightText(txt, lang, mode == 'multiline', opt); } /** * Call highlightElement on element with a css class starting with `shj-lang-` * * @async * @function highlightAll * @param {ShjOptions} [opt={}] Customization options */ export let highlightAll = async (opt) => Promise.all( Array.from(document.querySelectorAll('[class*="shj-lang-"]')) .map(elm => highlightElement(elm, undefined, undefined, opt))) /** * @typedef {{ match: RegExp, type: string } * | { match: RegExp, sub: string | ShjLanguageDefinition | (code:string) => ShjLanguageComponent } * | { expand: string } * } ShjLanguageComponent */ /** * @typedef {ShjLanguageComponent[]} ShjLanguageDefinition */ /** * Load a language and add it to the langs object * * @function loadLanguage * @param {string} languageName The name of the language * @param {{ default: ShjLanguageDefinition }} language The language */ export let loadLanguage = (languageName, language) => { langs[languageName] = language; } ================================================ FILE: public/lib/speed-highlight/languages/css.js ================================================ export default [ { match: /\/\*((?!\*\/)[^])*(\*\/)?/g, sub: 'todo' }, { expand: 'str' }, { type: 'kwd', match: /@\w+\b|\b(and|not|only|or)\b|\b[a-z-]+(?=[^{}]*{)/g }, { type: 'var', match: /\b[\w-]+(?=\s*:)|(::?|\.)[\w-]+(?=[^{}]*{)/g }, { type: 'func', match: /#[\w-]+(?=[^{}]*{)/g }, { type: 'num', match: /#[\da-f]{3,8}/g }, { type: 'num', match: /\d+(\.\d+)?(cm|mm|in|px|pt|pc|em|ex|ch|rem|vm|vh|vmin|vmax|%)?/g, sub: [ { type: 'var', match: /[a-z]+|%/g } ] }, { match: /url\([^)]*\)/g, sub: [ { type: 'func', match: /url(?=\()/g }, { type: 'str', match: /[^()]+/g } ] }, { type: 'func', match: /\b[a-zA-Z]\w*(?=\s*\()/g }, { type: 'num', match: /\b[a-z-]+\b/g } ] ================================================ FILE: public/lib/speed-highlight/languages/html.js ================================================ import xml, { property, xmlElement } from './xml.js' export default [ { type: 'class', match: /])*>/gi, sub: [ { type: 'str', match: /"[^"]*"|'[^']*'/g }, { type: 'oper', match: /^$/g }, { type: 'var', match: /DOCTYPE/gi } ] }, { match: RegExp(`((?!)[^])*`, 'g'), sub: [ { match: RegExp(`^`, 'g'), sub: xmlElement.sub }, { match: RegExp(`${xmlElement.match}|[^]*(?=$)`, 'g'), sub: 'css' }, xmlElement ] }, { match: RegExp(`((?!)[^])*`, 'g'), sub: [ { match: RegExp(`^`, 'g'), sub: xmlElement.sub }, { match: RegExp(`${xmlElement.match}|[^]*(?=$)`, 'g'), sub: 'js' }, xmlElement ] }, ...xml ] ================================================ FILE: public/lib/speed-highlight/languages/js.js ================================================ export default [ { match: /\/\*\*((?!\*\/)[^])*(\*\/)?/g, sub: 'jsdoc' }, { match: /\/\/.*\n?|\/\*((?!\*\/)[^])*(\*\/)?/g, sub: 'todo' }, { expand: 'str' }, { match: /`((?!`)[^]|\\[^])*`?/g, sub: 'js_template_literals' }, { type: 'kwd', 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 }, { match: /\/((?!\/)[^\r\n\\]|\\.)+\/[dgimsuy]*/g, sub: 'regex' }, { expand: 'num' }, { type: 'num', match: /\b(NaN|null|undefined|[A-Z][A-Z_]*)\b/g }, { type: 'bool', match: /\b(true|false)\b/g }, { type: 'oper', match: /[/*+:?&|%^~=!,<>.^-]+/g }, { type: 'class', match: /\b[A-Z][\w_]*\b/g }, { type: 'func', match: /[a-zA-Z$_][\w$_]*(?=\s*((\?\.)?\s*\(|=\s*(\(?[\w,{}\[\])]+\)? =>|function\b)))/g } ] ================================================ FILE: public/lib/speed-highlight/languages/js_template_literals.js ================================================ export default [ { match: new class { exec(str) { let i = this.lastIndex, j, f = _ => { while (++i < str.length - 2) if (str[i] == '{') f(); else if (str[i] == '}') return; }; for (; i < str.length; i++) if (str[i - 1] != '\\' && str[i] == '$' && str[i + 1] == '{') { j = i++; f(i); this.lastIndex = i + 1; return { index: j, 0: str.slice(j, i + 1) }; } return null; } }(), sub: [ { type: 'kwd', match: /^\${|}$/g }, { match: /(?!^\$|{)[^]+(?=}$)/g, sub: 'js' }, ], }, ]; export let type = 'str'; ================================================ FILE: public/lib/speed-highlight/languages/jsdoc.js ================================================ import todo from './todo.js'; export default [ { type: 'kwd', match: /@\w+/g }, { type: 'class', match: /{[\w\s|<>,.@\[\]]+}/g }, { type: 'var', match: /\[[\w\s="']+\]/g }, ...todo ]; export let type = 'cmnt'; ================================================ FILE: public/lib/speed-highlight/languages/json.js ================================================ export default [ { type: 'var', match: /("|')?[a-zA-Z]\w*\1(?=\s*:)/g }, { expand: 'str' }, { expand: 'num' }, { type: 'num', match: /\bnull\b/g }, { type: 'bool', match: /\b(true|false)\b/g } ] ================================================ FILE: public/lib/speed-highlight/languages/log.js ================================================ export default [ { type: 'cmnt', match: /^#.*/gm }, { expand: 'strDouble' }, { expand: 'num' }, { type: 'err', match: /\b(err(or)?|[a-z_-]*exception|warn|warning|failed|ko|invalid|not ?found|alert|fatal)\b/gi }, { type: 'num', match: /\b(null|undefined)\b/gi }, { type: 'bool', match: /\b(false|true|yes|no)\b/gi }, { type: 'oper', match: /\.|,/g } ] ================================================ FILE: public/lib/speed-highlight/languages/plain.js ================================================ export default [ { expand: 'strDouble' } ] ================================================ FILE: public/lib/speed-highlight/languages/regex.js ================================================ export default [ { match: /^(?!\/).*/gm, sub: 'todo' }, { type: 'num', match: /\[((?!\])[^\\]|\\.)*\]/g }, { type: 'kwd', match: /\||\^|\$|\\.|\w+($|\r|\n)/g }, { type: 'var', match: /\*|\+|\{\d+,\d+\}/g } ]; export let type = 'oper'; ================================================ FILE: public/lib/speed-highlight/languages/todo.js ================================================ export default [ { type: 'err', match: /\b(TODO|FIXME|DEBUG|OPTIMIZE|WARNING|XXX|BUG)\b/g }, { type: 'class', match: /\bIDEA\b/g }, { type: 'insert', match: /\b(CHANGED|FIX|CHANGE)\b/g }, { type: 'oper', match: /\bQUESTION\b/g } ]; export let type = 'cmnt'; ================================================ FILE: public/lib/speed-highlight/languages/ts.js ================================================ import js from './js.js' export default [ { type: 'type', match: /:\s*(any|void|number|boolean|string|object|never|enum)\b/g }, { type: 'kwd', match: /\b(type|namespace|typedef|interface|public|private|protected|implements|declare|abstract|readonly)\b/g }, ...js ] ================================================ FILE: public/lib/speed-highlight/languages/uri.js ================================================ export default [ { match: /^#.*/gm, sub: 'todo' }, { type: 'class', match: /^\w+(?=:?)/gm }, { type: 'num', match: /:\d+/g }, { type: 'oper', match: /[:/&?]|\w+=/g }, { type: 'func', match: /[.\w]+@|#[\w]+$/gm }, { type: 'var', match: /\w+\.\w+(\.\w+)*/g } ] ================================================ FILE: public/lib/speed-highlight/languages/xml.js ================================================ export let property = '\\s*(\\s+[a-z-]+\\s*(=\\s*([^"\']\\S*|("|\')(\\\\[^]|(?!\\4)[^])*\\4?)?)?\\s*)*', xmlElement = { match: RegExp(`<\/?[a-z_-]+${property}\/?>`, 'g'), sub: [ { type: 'var', match: /^<\/?[^\s>\/]+/g, sub: [ { type: 'oper', match: /^<\/?/g } ] }, { type: 'str', match: /=\s*([^"']\S*|("|')(\\[^]|(?!\2)[^])*\2?)/g, sub: [ { type: 'oper', match: /^=/g } ] }, { type: 'oper', match: /\/?>/g }, { type: 'class', match: /[a-z-]+/gi } ] }; export default [ { match: /)[^])*-->/g, sub: 'todo' }, { type: 'class', match: RegExp(`<\\?xml${property}\\?>`, 'gi'), sub: [ { type: 'oper', match: /^<\?|\?>$/g }, { type: 'str', match: /"[^"]*"|'[^']*'/g }, { type: 'var', match: /xml/gi } ] }, { type: 'class', match: //gi }, xmlElement, { type: 'var', match: /&(#x?)?[\da-z]{1,8};/gi } ] ================================================ FILE: public/lib/speed-highlight/themes/default.css ================================================ [class*="shj-lang-"] { white-space: pre; /* margin: 10px 0;*/ /* border-radius: 10px;*/ /* padding: 30px 20px;*/ background: white; color: #112; /* box-shadow: 0 0 5px #0001;*/ text-shadow: none; /* font: normal 18px Consolas, "Courier New", Monaco, 'Andale Mono', 'Ubuntu Mono', monospace;*/ line-height: 24px; box-sizing: border-box; max-width: min(100%, 100vw) } .shj-inline { /* margin: 0;*/ /* padding: 2px 5px;*/ display: inline-block; /* border-radius: 5px*/ } [class*="shj-lang-"]::selection, [class*="shj-lang-"] ::selection {background: #bdf5} [class*="shj-lang-"] > div { display: flex; overflow: auto } [class*="shj-lang-"] > div :last-child { flex: 1; outline: none } .shj-numbers { padding-left: 5px; counter-reset: line } .shj-numbers div {padding-right: 5px} .shj-numbers div::before { color: #999; display: block; content: counter(line); opacity: .5; text-align: right; margin-right: 5px; counter-increment: line } .shj-syn-cmnt {font-style: italic} .shj-syn-err, .shj-syn-kwd {color: #e16} .shj-syn-num, .shj-syn-class {color: #f60} .shj-numbers, .shj-syn-cmnt {color: #999} .shj-syn-insert, .shj-syn-str {color: #7d8} .shj-syn-bool {color: #3bf} .shj-syn-type, .shj-syn-oper {color: #5af} .shj-syn-section, .shj-syn-func {color: #84f} .shj-syn-deleted, .shj-syn-var {color: #f44} .shj-oneline {padding: 12px 10px} .shj-lang-http.shj-oneline .shj-syn-kwd { background: #25f; color: #fff; padding: 5px 7px; border-radius: 5px } ================================================ FILE: public/lib/speed-highlight/themes/github-dark.css ================================================ @import 'default.css'; [class*="shj-lang-"] { color: #c9d1d9; background: #161b22 } [class*="shj-lang-"]:before {color: #6f9aff} .shj-syn-insert {color: #98c379} .shj-syn-deleted, .shj-syn-err, .shj-syn-kwd {color: #ff7b72} .shj-syn-class {color: #ffa657} .shj-numbers, .shj-syn-cmnt {color: #8b949e} .shj-syn-type, .shj-syn-oper, .shj-syn-num, .shj-syn-section, .shj-syn-var, .shj-syn-bool {color: #79c0ff} .shj-syn-str {color: #a5d6ff} .shj-syn-func {color: #d2a8ff} ================================================ FILE: public/lib/speed-highlight/themes/github-light.css ================================================ @import 'default.css'; [class*="shj-lang-"] { color: #24292f; background: #fff } .shj-syn-deleted, .shj-syn-err, .shj-syn-kwd {color: #cf222e} .shj-syn-class {color: #953800} .shj-numbers, .shj-syn-cmnt {color: #6e7781} .shj-syn-type, .shj-syn-oper, .shj-syn-num, .shj-syn-section, .shj-syn-var, .shj-syn-bool {color: #0550ae} .shj-syn-str {color: #0a3069} .shj-syn-func {color: #8250df} ================================================ FILE: public/manifest.json ================================================ { "name": "Plain Vanilla", "short_name": "Plain Vanilla", "icons": [{ "src": "android-chrome-512x512.png", "sizes": "512x512" }], "background_color": "#ffffff", "description": "An explainer for doing web development using only vanilla techniques.", "theme_color": "#ffffff", "display": "fullscreen" } ================================================ FILE: public/pages/applications.html ================================================ Plain Vanilla - Applications

Applications

#

Project

When richer interactivity and dynamic state are needed, a single-page application is often a better fit than a multi-page website.

The suggested project layout for single-page applications is the same as for multi-page sites, except:

/public/pages
As there is only index.html there is no need for the pages folder.
/public/app
All of the views and routes for the application are in this folder, each implemented as a web component, and registered in index.js.
/public/app/App.js
As in the major frameworks, the application is bootstrapped from an App component. See the example below.
#

Routing

Without the assistance of a server to do routing, the easiest option is hash-based routing:

  • The current route is in window.location.hash, e.g. #/about.
  • The route's changes are detected by listening to the Window hashchange event.
  • Each web component is shown or hidden based on the active route.

This behavior can be encapsulated in a routing web component:

An example single-page vanilla application that uses this routing component:

It makes use of the template pattern to avoid showing a broken application if scripting is disabled.

Adding additional route components to the /app folder is left as an exercise for the reader.

For a more advanced but less intuitive client-side routing solution that uses pushState, see the article on Clean Client-side Routing.

#

Entity encoding

A real-world application will often have complex markup in the web components, filled with variables based on user input. This creates a risk for Cross-Site Scripting. In fact, eagle-eyed readers may have noticed in the Passing Data example of the Components page that a XSS bug snuck in. By entering the following as a name, the output of list.js would have code injected:
<button onclick="alert('gotcha')">oops</button>

Go ahead and return to that page to try it ...

To solve this we need to encode dangerous HTML entities while plugging variables into HTML markup, something frameworks often do automatically in their templating layer. This html`` literal function can be used to do this automatically in a vanilla codebase:

The reworked list.js that uses this:

To learn more on using this function, check the html-literal documentation.

#

Managing state

Where state lives

State is the source of truth of what the application should show. It is the data that gets turned into markup on the page by the application's logic.

In web frameworks state is often carefully managed so that it lives outside of the DOM, and then rendered into the DOM using a view layer. Every time the state is modified, the view layer rerenders the current view based on the new state, updating the DOM behind the scenes to match the new view. In this design the DOM is just a view on the state, but does not actually contain the state.

In vanilla web development however, state and view are merged together inside of a web component. The component carries its state in attributes and properties, making it part of the DOM, and it updates its appearance based on changes in that state in a self-contained way. In this design the DOM ends up being the owner of the state, not just a view on that state.

Take for example a simple counter:

The <x-counter> component carries its state in the #count property, it provides an API for safely changing the state with the increment() method, and it always updates its appearance when the state is changed using the update() method.

Lifting state up

Putting state inside of web components as attributes and properties at first can seem simple, but when scaling this up to more complex hierarchies of components it quickly becomes difficult to manage. This means care must be taken to organize state across the component hierarchy in the right way.

Generally speaking, the state management principles laid out in the React documentation are sound and should be followed even for vanilla web development. Here they are once again:

  1. Group related state. If you always update two or more state variables at the same time, consider merging them into a single state variable.
  2. Avoid contradictions in state. When the state is structured in a way that several pieces of state may contradict and “disagree” with each other, you leave room for mistakes. Try to avoid this.
  3. Avoid redundant state. If you can calculate some information from the component's attributes or properties during rendering, you should not put that information into that component's state.
  4. Avoid duplication in state. When the same data is duplicated between multiple state variables, or within nested objects, it is difficult to keep them in sync. Reduce duplication when you can.
  5. Avoid deeply nested state. Deeply hierarchical state is not very convenient to update. When possible, prefer to structure state in a flat way.

Let's look at these principes in action by porting the React lifting state up tutorial example application to vanilla code:

The implementation is divided across two web components: <x-accordion> and <x-panel>. The state is "lifted up" from the panels onto the accordion, so that the accordion carries the state for both panels in a single central place.

Each of the two panels is stateless. It receives its state through the title and active properties. When it is active, it shows its children (inside of a slot). When it is not active, it shows a button labeled "Show". It always shows the title.

By contrast, the accordion is where the state for the panels actually lives:

What to pay attention to:

  • The accordion's activeIndex property carries the state, and everything else is derived from that. This property becomes the single source of truth for the application, avoiding redundant state.
  • An event listener for the show event sent by a panel will set activeIndex to the right value. The property setter for activeIndex explicitly calls the update() method to bring the rest of the DOM in sync with the property's new state.

Finally, take a look at the original implementation of Accordion and Panel in React's tutorial:

Take note of how the state is organized the same across the React and vanilla implementations. The differences are in implementation details for state and rendering, not in how the application is structured.

Passing data deeply

While passing state deep into a hierarchy by handing it from parent components to child components via attributes or properties works, it can quickly become verbose and inconvenient. This is especially the case if you have to pass those through many components in the middle which have no need for that state aside from passing it to their child components, an anti-pattern colloquially known as "prop drilling".

Again we can take inspiration from how popular frameworks like React organize state, by adapting the concept of a context. A context holds state at a high level in the component hierarchy, and it can be accessed directly from anywhere in that hierarchy. The whole concept of a context is explained in the React passing data deeply tutorial.

To understand how to apply this concept in vanilla web development let us look at the ThemeContext example from the useContext documentation page. Here is an adapted vanilla version that uses a central context to keep track of light or dark theme, toggled by a button.

In this example a special web component <x-theme-context> is created, whose only job is to keep track of state, provide setters to update that state, and dispatch events when the state changes.

Some key take-aways:

  • The x-theme-context component applies the context protocol, a convention for how web components can implement a context pattern. It does this by making use of a minimal implementation of the protocol provided in tiny-context.js.
  • The context component also uses display: contents to avoid impacting the layout. It exists in the DOM hierarchy, but it becomes effectively invisible.
  • Instead of useContext every component obtains the nearest theme context by dispatching a context-request event, whose callback will be answered by the nearest provider higher up in the DOM that provides the asked for context. By passing true as the subscribe parameter the components can subscribe to updates.
  • The theme-toggle function is also provided to the button by the context component. This mechanism can be used for dependency injection across web components.

The needs more context article does a deeper dive into the context protocol and the tiny-context implementation of it.

Up next


Go build something vanilla!

(Or keep reading on the blog.)

================================================ FILE: public/pages/components.html ================================================ Plain Vanilla - Components

Components

#

What are they?

Web Components are a set of technologies that allow us to extend the standard set of HTML elements with additional elements.

The three main technologies are:

Custom elements
A way to extend HTML so that instead of having to build all our markup out of <div>, <input>, <span> and friends, we can build with higher-level primitives.
Shadow DOM
Extending custom elements to have their own separate DOM, isolating complex behavior inside the element from the rest of the page.
HTML templates
Extending custom elements with reusable markup blocks using the <template> and <slot> tags, for quickly generating complex layouts.

Those 3 bullets tell you everything and nothing at the same time. This probably isn't the first tutorial on Web Components you've seen, and you may find them a confusing topic. However, they're not that complicated as long as you build them up step by step ...

#

A simple component

Let's start with the most basic form, a custom element that says 'hello world!':

We can use it in a page like this:

Which outputs this page:

So what's happening here?

We created a new HTML element, registered as the x-hello-world tag, and used it on the page. When we did that, we got the following DOM structure:

  • body (node)
    • x-hello-world (node)
      • 'hello world!' (textContent)

Explaining the code of the custom element line by line:

class HelloWorldComponent extends HTMLElement {
Every custom element is a class extending HTMLElement. In theory it's possible to extend other classes – like HTMLButtonElement to extend a <button> – but in practice this doesn't work in Safari.
connectedCallback() {
This method is called when our element is added to the DOM, which means the element is ready to make DOM updates. Note that it may be called multiple times when the element or one of its ancestors is moved around the DOM.
this.textContent = 'hello world!';
The this in this case refers to our element, which has the full HTMLElement API, including its ancestors Element and Node, on which we can find the textContent property which is used to add the 'hello world!' string to the DOM.
customElements.define('x-hello-world', HelloWorldComponent);
For every web component window.customElements.define must be called once to register the custom element's class and associate it with a tag. After this line is called the custom element becomes available for use in HTML markup, and existing uses of it in already rendered markup will have their constructors called.
#

An advanced component

While the simple version above works for a quick demo, you'll probably want to do more pretty quickly:

  • Adding DOM elements as children to allow for richer content.
  • Passing in attributes, and updating the DOM based on changes in those attributes.
  • Styling the element, preferably in a way that's isolated and scales nicely.
  • Defining all custom elements from a central place, instead of dumping random script tags in the middle of our markup.

To illustrate a way to do those things with custom elements, here's a custom element <x-avatar> that implements a simplified version of the HeroUI Avatar component (React):

Some key elements that have changed:

  • The observedAttributes getter returns the element's attributes that when changed cause attributeChangedCallback() to be called by the browser, allowing us to update the UI.
  • The connectedCallback method is written in the assumption that it will be called multiple times. This method is in fact called when the element is first added to the DOM, but also when it is moved around.
  • The update() method handles initial render as well as updates, centralizing the UI logic. Note that this method is written in a defensive way with the if statement, because it may be called from the attributeChangedCallback() method before connectedCallback() creates the <img> element.
  • The exported registerAvatarComponent function allows centralizing the logic that defines all custom elements in an application.

Once rendered this avatar component will have this DOM structure:

  • body (node)
    • x-avatar (node)
      • img (node)
        • src (attribute)
        • alt (attribute)

For styling of our component we can use a separate css file:

Notice that:

  • Because we know what the tag of our component is, we can easily scope the styles by prepending them with x-avatar, so they won't conflict with the rest of the page.
  • Because a custom element is just HTML, we can style based on the element's custom attributes in pure CSS, like the size attribute which resizes the component without any JavaScript.

An example that shows the two different sizes on a webpage:

The HTML for this example centralizes the JavaScript and CSS logic to two index files, to make it easier to scale out to more web components. This pattern, or a pattern like it, can keep things organized in a web application that is built out of dozens or hundreds of different web components.

The <script> tag in this example has moved into the <head> and picked up a defer attribute. This will allow it to download in parallel with the rest of the page, and have it execute right when the page is loaded.

In index.css the use of the @import keyword may seem surprising as this keyword is often frowned upon for performance reasons. However, in modern browsers over HTTP/2 and in particular HTTP/3 the performance penalty of this keyword is not that severe, especially because files included with @import now download in parallel.

#

Adding Children

Allowing children to be added to a web component is not hard. In fact, it is the default behavior. To see how this works, let's extend the avatar example by wrapping it with a badge:

To clarify, this is the DOM structure that is created:

  • div (node)
    • x-badge (node)
      • content (attribute)
      • span (node, showing content)
      • x-avatar (node)
    • input (node)

The x-avatar component is identical to the previous example, but how does the x-badge work?

Some notes on what's happening here:

this.insertBefore

Care must be taken to not overwrite the children already added in the markup, for example by assigning to innerHTML. In this case the span that shows the badge is inserted before the child elements.

This also means that for custom elements that should not have children, this can be enforced by calling this.innerHTML = '' from connectedCallback().

set content(value) {
Custom element attributes can only be accessed from JavaScript through the setAttribute() and getAttribute() methods. To have a cleaner JavaScript API a setter and getter must be created for a class property that wraps the custom element's content attribute. See the index.html above for where this is called.
#

Bells and whistles

Having seen what regular web components look like, we're now ready to jump up to the final difficulty level of web components, leveraging the more advanced features of Shadow DOM and HTML templates.

This can all be brought together in this page layout example, that defines a new <x-header> component:

This is the code for the newly added <x-header> component:

There's a lot happening in header.js, so let's unpack.

const template = document.createElement('template');
The header code starts out by creating an HTML template. Templates are fragments of HTML that can be easily cloned and appended to a DOM. For complex web components that have a lot of markup, the use of a template is often convenient. By instantiating the template outside the class, it can be reused across all instances of the <x-header> component.
<link rel="stylesheet" href="${import.meta.resolve('./header.css')}">
Because this component uses a shadow DOM, it is isolated from the styles of the containing page and starts out unstyled. The header.css needs to be imported into the shadow DOM using the <link> tag. The special import.meta.resolve trick imports the CSS file from the same path as the header.js file.
<slot></slot>
The <slot> element is where the child elements will go (like the <x-badge> child of <x-header>). Putting child elements in a slot is similar to using a children prop in a React component. The use of slots is only possible in web components that have a shadow DOM.
constructor() {

This is the first example that uses a constructor. The constructor is called when the element is first created, but before it's ready for DOM interaction. The default behavior of a constructor is to call the parent class's constructor super(). So if all that is needed is the default HTMLElement constructor behavior then no constructor needs to be specified.

The reason it is specified here is because the constructor is guaranteed to be called exactly once, which makes it the ideal place to attach a shadow DOM.

if (!this.shadowRoot) { this.attachShadow({ mode: 'open' });

attachShadow attaches a shadow DOM to the current element, an isolated part of the DOM structure with CSS separated from the containing page, and optionally with the shadow content hidden away from the parent page's JavaScript context (if mode: 'closed' is set). For web components that are used in a known codebase, it is usually more convenient to use them in open mode, as is done here.

if (!this.shadowRoot) { is not strictly necessary, but allows for server-side generated HTML, by making use of declarative shadow DOM.

this.shadowRoot.append(template.content.cloneNode(true));

The shadowRoot property is the root element of the attached shadow DOM, and is rendered into the page as the <x-header> element's content. The HTML template is cloned and appended into it.

The shadow DOM becomes immediately available as soon as attachShadow is called, which is why the template can be appended in the constructor, and why the update() method can be called there. For custom elements without shadow DOM rendering the element's content should be deferred until connectedCallback().

All the new files of this example put together:

As you can see in header.css, styling the content of a shadow DOM is a bit different:

  • The :host pseudo-selector applies styles to the element from the light DOM that hosts the shadow DOM (or in other words, to the custom element itself).
  • The other styles (like h1 in this example) are isolated inside the shadow DOM.
  • The shadow DOM starts out unstyled, which is why reset.css is imported again.
#

Passing Data

Everything up to this point assumes that data passed between web components is very simple, just simple numeric and string attributes passing down. A real world web application however passes complex data such as objects and arrays between parent and child components.

This example demonstrates the three major ways that data can be passed between web components:

Events

The first way is passing events, usually from child components to their parent component. This is demonstrated by the form at the top of the example.

Every time the Add button is pressed a CustomEvent of type add is dispatched using the dispatchEvent method. The event data's detail property carries the submitted form data.

The event is handled one level up:

The update() method sends the updated list back down to the <santas-list> and <santas-summary> components, using the next two methods.

Properties

The second way to pass complex data is by using class properties, as exemplified by the <santas-list> component:

The list setter calls the update() method to rerender the list.

This is the recommended way to pass complex data to stateful web components.

The best practice way of implementing attributes, properties and events is subtle and opinionated. The article The Attribute-Property Duality dives into this topic in depth, and is recommended reading when making web components that will be embedded in third-party sites or are otherwise expected to behave like built-in elements.

Methods

The third way to pass complex data is by calling a method on the web component, as exemplified by the <santas-summary> component:

This is the recommended way to pass complex data to stateless web components.

Complete example

Finally then, here is all the code for the Santa's List application:

Up next


Learn about styling Web Components in ways that are encapsulated and reusable.

Add Styling

================================================ FILE: public/pages/examples/applications/counter/components/counter.js ================================================ class Counter extends HTMLElement { #count = 0; increment() { this.#count++; this.update(); } connectedCallback() { this.update(); } update() { this.textContent = this.#count; } } export const registerCounterComponent = () => customElements.define('x-counter', Counter); ================================================ FILE: public/pages/examples/applications/counter/index.html ================================================

Let's count to .

================================================ FILE: public/pages/examples/applications/counter/index.js ================================================ import { registerCounterComponent } from './components/counter.js'; const app = () => { registerCounterComponent(); } document.addEventListener('DOMContentLoaded', app); ================================================ FILE: public/pages/examples/applications/lifting-state-up/components/accordion.js ================================================ class Accordion extends HTMLElement { #activeIndex = 0; get activeIndex () { return this.#activeIndex; } set activeIndex(index) { this.#activeIndex = index; this.update(); } connectedCallback() { this.innerHTML = `

Almaty, Kazakhstan

With a population of about 2 million, Almaty is Kazakhstan's largest city. From 1929 to 1997, it was its capital city. 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. `; this.querySelectorAll('x-panel').forEach((panel, index) => { panel.addEventListener('show', () => { this.activeIndex = index; }); }) this.update(); } update() { this.querySelectorAll('x-panel').forEach((panel, index) => { panel.setAttribute('active', index === this.activeIndex); }); } } export const registerAccordionComponent = () => { customElements.define('x-accordion', Accordion); } ================================================ FILE: public/pages/examples/applications/lifting-state-up/components/panel.js ================================================ class Panel extends HTMLElement { constructor() { super(); this.attachShadow({ mode: 'open' }); } connectedCallback() { this.shadowRoot.innerHTML = `

`; this.shadowRoot.querySelector('button').onclick = () => this.dispatchEvent(new CustomEvent('show')); this.update(); } static get observedAttributes() { return ['title', 'active']; } attributeChangedCallback() { this.update(); } update() { const heading = this.shadowRoot.querySelector('h3'); const slot = this.shadowRoot.querySelector('slot'); const button = this.shadowRoot.querySelector('button'); if (heading && slot && button) { heading.textContent = this.title; slot.style.display = this.getAttribute('active') === 'true' ? 'block' : 'none'; button.style.display = this.getAttribute('active') === 'true' ? 'none' : 'inline'; } } } export const registerPanelComponent = () => customElements.define('x-panel', Panel); ================================================ FILE: public/pages/examples/applications/lifting-state-up/index.css ================================================ body { font-family: system-ui, sans-serif; } ================================================ FILE: public/pages/examples/applications/lifting-state-up/index.html ================================================ ================================================ FILE: public/pages/examples/applications/lifting-state-up/index.js ================================================ import { registerAccordionComponent } from './components/accordion.js'; import { registerPanelComponent } from './components/panel.js'; const app = () => { registerAccordionComponent(); registerPanelComponent(); } document.addEventListener('DOMContentLoaded', app); ================================================ FILE: public/pages/examples/applications/lifting-state-up/react/App.js ================================================ import { useState } from 'react'; export default function Accordion() { const [activeIndex, setActiveIndex] = useState(0); return ( <>

Almaty, Kazakhstan

setActiveIndex(0)} > With a population of about 2 million, Almaty is Kazakhstan's largest city. From 1929 to 1997, it was its capital city. setActiveIndex(1)} > 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. ); } function Panel({ title, children, isActive, onShow }) { return (

{title}

{isActive ? (

{children}

) : ( )}
); } ================================================ FILE: public/pages/examples/applications/passing-data-deeply/components/button.js ================================================ import { ContextRequestEvent } from "../lib/tiny-context.js"; class ButtonComponent extends HTMLElement { constructor() { super(); this.attachShadow({ mode: 'open' }); } #theme = 'light'; #unsubscribe; connectedCallback() { this.shadowRoot.innerHTML = ` `; this.dispatchEvent(new ContextRequestEvent('theme', (theme, unsubscribe) => { this.#theme = theme; this.#unsubscribe = unsubscribe; this.update(); }, true)); this.dispatchEvent(new ContextRequestEvent('theme-toggle', (toggle) => { this.shadowRoot.querySelector('button').onclick = toggle; })); this.update(); } disconnectedCallback() { this.#unsubscribe?.(); } update() { const button = this.shadowRoot.querySelector('button'); if (button) button.className = 'button-' + this.#theme; } } export const registerButtonComponent = () => customElements.define('x-button', ButtonComponent); ================================================ FILE: public/pages/examples/applications/passing-data-deeply/components/panel.js ================================================ import { ContextRequestEvent } from "../lib/tiny-context.js"; class PanelComponent extends HTMLElement { constructor() { super(); this.attachShadow({ mode: 'open' }); } #theme = 'light'; #unsubscribe; connectedCallback() { this.shadowRoot.innerHTML = `

`; this.dispatchEvent(new ContextRequestEvent('theme', (theme, unsubscribe) => { this.#theme = theme; this.#unsubscribe = unsubscribe; this.update(); }, true)); this.update(); } disconnectedCallback() { this.#unsubscribe?.(); } static get observedAttributes() { return ['title']; } attributeChangedCallback() { this.update(); } update() { const h1 = this.shadowRoot.querySelector('h1'); const section = this.shadowRoot.querySelector('section'); if (section && h1) { section.className = 'panel-' + this.#theme; h1.textContent = this.getAttribute('title'); } } } export const registerPanelComponent = () => customElements.define('x-panel', PanelComponent); ================================================ FILE: public/pages/examples/applications/passing-data-deeply/components/theme-context.js ================================================ import { ContextProvider } from "../lib/tiny-context.js"; class ThemeContext extends HTMLElement { themeProvider = new ContextProvider(this, 'theme', 'light'); toggleProvider = new ContextProvider(this, 'theme-toggle', () => { this.themeProvider.value = this.themeProvider.value === 'light' ? 'dark' : 'light'; }); connectedCallback() { this.style.display = 'contents'; } } export const registerThemeContext = () => customElements.define('x-theme-context', ThemeContext); ================================================ FILE: public/pages/examples/applications/passing-data-deeply/index.css ================================================ :root { font-family: sans-serif; } body { margin: 20px; padding: 0; } * { box-sizing: border-box; } h1 { margin-top: 0; font-size: 22px; } .panel-light, .panel-dark { border: 1px solid black; border-radius: 4px; padding: 20px; } .panel-light { color: #222; background: #fff; } .panel-dark { color: #fff; background: rgb(23, 32, 42); } .button-light, .button-dark { border: 1px solid #777; padding: 5px; margin-right: 10px; margin-top: 10px; } .button-dark { background: #222; color: #fff; } .button-light { background: #fff; color: #222; } ================================================ FILE: public/pages/examples/applications/passing-data-deeply/index.html ================================================ Toggle theme ================================================ FILE: public/pages/examples/applications/passing-data-deeply/index.js ================================================ import { registerThemeContext } from './components/theme-context.js'; import { registerPanelComponent } from './components/panel.js'; import { registerButtonComponent } from './components/button.js'; const app = () => { registerThemeContext(); registerPanelComponent(); registerButtonComponent(); } document.addEventListener('DOMContentLoaded', app); ================================================ FILE: public/pages/examples/applications/passing-data-deeply/lib/tiny-context.js ================================================ export class ContextRequestEvent extends Event { constructor(context, callback, subscribe) { super('context-request', { bubbles: true, composed: true, }); this.context = context; this.callback = callback; this.subscribe = subscribe; } } export class ContextProvider extends EventTarget { #value; get value() { return this.#value } set value(v) { this.#value = v; this.dispatchEvent(new Event('change')); } #context; get context() { return this.#context } constructor(target, context, initialValue = undefined) { super(); this.#context = context; this.#value = initialValue; if (target) this.attach(target); } attach(target) { target.addEventListener('context-request', this); } detach(target) { target.removeEventListener('context-request', this); } /** * Handle a context-request event * @param {ContextRequestEvent} e */ handleEvent(e) { if (e.context === this.context) { if (e.subscribe) { const unsubscribe = () => this.removeEventListener('change', update); const update = () => e.callback(this.value, unsubscribe); this.addEventListener('change', update); update(); } else { e.callback(this.value); } e.stopPropagation(); } } } ================================================ FILE: public/pages/examples/applications/single-page/app/App.js ================================================ class App extends HTMLElement { connectedCallback() { this.innerHTML = `

Basic Example

This example demonstrates how the features of a framework router can be approximated using web components and a vanilla hash router.

Check out the original React Router basic example for comparison.

Home

About

Dashboard

Nothing to see here

Go to the home page
`; } } class AppLayout extends HTMLElement { connectedCallback() { this.innerHTML = `
`; } } export const registerApp = () => { customElements.define('x-app', App); customElements.define('x-app-layout', AppLayout); } ================================================ FILE: public/pages/examples/applications/single-page/components/route/route.js ================================================ /** * Usage: *

hello

= only match #/ (or no hash) and show the text "hello" * = match every route below / (e.g. for site navigation) * = only match #/about exactly * = match #/todos/:id and pass id to routeChangedCallback * = match #/notebooks/:id and /notebooks/:id/:note and pass id and note to routeChangedCallback * = match if no other route matches within the same parent node */ export class RouteComponent extends HTMLElement { constructor() { super(); this.update = this.update.bind(this); this.style.display = 'contents'; } #isActive = false; get isActive() { return this.#isActive; } connectedCallback() { this.classList.toggle('route', true); window.addEventListener('hashchange', this.update); this.update(); } disconnectedCallback() { window.removeEventListener('hashchange', this.update); } static get observedAttributes() { return ['path', 'exact']; } attributeChangedCallback() { this.update(); } update() { const path = this.getAttribute('path') || ''; const exact = this.hasAttribute('exact'); const matches = this.#matchesRoute(path, exact); this.#isActive = !!matches; this.setIsActive(this.#isActive); this.routeChangedCallback.apply(this, matches ? matches.slice() : []); } // can be overridden in subclasses to change show/hide method setIsActive(active) { this.style.display = active ? 'contents' : 'none'; } // for overriding in subclasses to detect parameters // eslint-disable-next-line no-unused-vars routeChangedCallback(...matches) {} #matchesRoute(path, exact) { let matches; // '*' triggers fallback route if no other route matches if (path === '*') { const activeRoutes = Array.from(this.parentNode.querySelectorAll('.route')).filter(_ => _.isActive); if (!activeRoutes.length) matches = ['*']; // normal routes } else { const regex = new RegExp(`^#${path.replaceAll('/', '\\/')}${exact ? '$' : ''}`, 'gi'); const currentPath = window.location.hash || '#/'; matches = regex.exec(currentPath); } return matches; } } export const registerRouteComponent = () => customElements.define('x-route', RouteComponent); ================================================ FILE: public/pages/examples/applications/single-page/index.css ================================================ body { font-family: system-ui, sans-serif; } ================================================ FILE: public/pages/examples/applications/single-page/index.html ================================================ Single-page Example ================================================ FILE: public/pages/examples/applications/single-page/index.js ================================================ import { registerApp } from "./app/App.js"; import { registerRouteComponent } from "./components/route/route.js"; const app = () => { registerRouteComponent(); registerApp(); const template = document.querySelector('template#root'); if (template) document.body.appendChild(template.content, true); } document.addEventListener('DOMContentLoaded', app); ================================================ FILE: public/pages/examples/components/adding-children/components/avatar.css ================================================ x-avatar { display: flex; align-items: center; justify-content: center; width: 2.5rem; height: 2.5rem; } x-avatar[size=lg] { width: 3.5rem; height: 3.5rem; } x-avatar img { border-radius: 9999px; width: 100%; height: 100%; vertical-align: middle; object-fit: cover; } ================================================ FILE: public/pages/examples/components/adding-children/components/avatar.js ================================================ /** * Usage: * * */ class AvatarComponent extends HTMLElement { connectedCallback() { if (!this.querySelector('img')) { this.append(document.createElement('img')); } this.update(); } static get observedAttributes() { return ['src', 'alt']; } attributeChangedCallback() { this.update(); } update() { const img = this.querySelector('img'); if (img) { img.src = this.getAttribute('src'); img.alt = this.getAttribute('alt') || 'avatar'; } } } export const registerAvatarComponent = () => { customElements.define('x-avatar', AvatarComponent); } ================================================ FILE: public/pages/examples/components/adding-children/components/badge.css ================================================ x-badge { position: relative; display: inline-flex; flex-shrink: 0; box-sizing: border-box; } x-badge > span.x-badge-label { /* size and position */ box-sizing: inherit; position: absolute; top: 0.2rem; right: 0.2rem; width: 1.25rem; height: 1.25rem; transform: translate(50%, -50%); z-index: 10; /* colors and fonts */ color: white; background-color: rgb(0, 111, 238); border-style: solid; border-color: #333333; border-width: 2px; border-radius: 9999px; font-size: 0.875rem; line-height: 1.2; /* text placement */ display: flex; place-content: center; user-select: none; } ================================================ FILE: public/pages/examples/components/adding-children/components/badge.js ================================================ class BadgeComponent extends HTMLElement { #span; connectedCallback() { if (!this.#span) { this.#span = document.createElement('span'); this.#span.className = 'x-badge-label'; } this.insertBefore(this.#span, this.firstChild); this.update(); } update() { if (this.#span) this.#span.textContent = this.getAttribute('content'); } static get observedAttributes() { return ['content']; } attributeChangedCallback() { this.update(); } set content(value) { if (this.getAttribute('content') !== value) { this.setAttribute('content', value); } } get content() { return this.getAttribute('content'); } } export const registerBadgeComponent = () => customElements.define('x-badge', BadgeComponent); ================================================ FILE: public/pages/examples/components/adding-children/index.css ================================================ @import "./components/avatar.css"; @import "./components/badge.css"; p, div { margin: 1em; font-family: sans-serif; } x-badge { vertical-align: middle; } ================================================ FILE: public/pages/examples/components/adding-children/index.html ================================================

Avatar and badge, when their powers combine...

================================================ FILE: public/pages/examples/components/adding-children/index.js ================================================ import { registerAvatarComponent } from './components/avatar.js'; import { registerBadgeComponent } from './components/badge.js'; const app = () => { registerAvatarComponent(); registerBadgeComponent(); } document.addEventListener('DOMContentLoaded', app); ================================================ FILE: public/pages/examples/components/advanced/components/avatar.css ================================================ x-avatar { display: flex; align-items: center; justify-content: center; width: 2.5rem; height: 2.5rem; } x-avatar[size=lg] { width: 3.5rem; height: 3.5rem; } x-avatar img { border-radius: 9999px; width: 100%; height: 100%; vertical-align: middle; object-fit: cover; } ================================================ FILE: public/pages/examples/components/advanced/components/avatar.js ================================================ /** * Usage: * * */ class AvatarComponent extends HTMLElement { connectedCallback() { if (!this.querySelector('img')) { this.append(document.createElement('img')); } this.update(); } static get observedAttributes() { return ['src', 'alt']; } attributeChangedCallback() { this.update(); } update() { const img = this.querySelector('img'); if (img) { img.src = this.getAttribute('src'); img.alt = this.getAttribute('alt') || 'avatar'; } } } export const registerAvatarComponent = () => { customElements.define('x-avatar', AvatarComponent); } ================================================ FILE: public/pages/examples/components/advanced/index.css ================================================ @import "./components/avatar.css"; body { font-family: monospace; } ================================================ FILE: public/pages/examples/components/advanced/index.html ================================================

A basic avatar component in two sizes:

================================================ FILE: public/pages/examples/components/advanced/index.js ================================================ import { registerAvatarComponent } from './components/avatar.js'; const app = () => { registerAvatarComponent(); } document.addEventListener('DOMContentLoaded', app); ================================================ FILE: public/pages/examples/components/advanced/simple.html ================================================ ================================================ FILE: public/pages/examples/components/data/components/app.js ================================================ class SantasApp extends HTMLElement { #theList = [/* { name, nice } */]; connectedCallback() { if (this.querySelector('h1')) return; this.innerHTML = `

Santa's List

`; this.querySelector('santas-form') .addEventListener('add', (e) => { this.#theList.push(e.detail.form); this.update(); }); this.update(); } update() { this.querySelector('santas-list').list = this.#theList.slice(); this.querySelector('santas-summary').update(this.#theList.slice()); } } export const registerApp = () => customElements.define('santas-app', SantasApp); ================================================ FILE: public/pages/examples/components/data/components/form.js ================================================ class SantasForm extends HTMLElement { connectedCallback() { if (this.querySelector('form')) return; this.innerHTML = `
`; this.querySelector('form').onsubmit = (e) => { e.preventDefault(); const data = new FormData(e.target); this.dispatchEvent(new CustomEvent('add', { detail: { form: Object.fromEntries(data.entries()) } })); e.target.reset(); } } } export const registerSantasForm = () => customElements.define('santas-form', SantasForm); ================================================ FILE: public/pages/examples/components/data/components/list-safe.js ================================================ import { html } from '../lib/html.js'; class SantasList extends HTMLElement { #currentList = [/* { name, nice } */]; set list(newList) { this.#currentList = newList; this.update(); } update() { this.innerHTML = '
    ' + this.#currentList.map(person => // the html`` literal automatically encodes entities in the variables html`
  • ${person.name} is ${person.nice ? 'nice' : 'naughty'}
  • ` ).join('\n') + '
'; } } export const registerSantasList = () => customElements.define('santas-list', SantasList); ================================================ FILE: public/pages/examples/components/data/components/list.js ================================================ class SantasList extends HTMLElement { #currentList = [/* { name, nice } */]; set list(newList) { this.#currentList = newList; this.update(); } update() { this.innerHTML = '
    ' + this.#currentList.map(person => `
  • ${person.name} is ${person.nice ? 'nice' : 'naughty'}
  • ` ).join('\n') + '
'; } } export const registerSantasList = () => customElements.define('santas-list', SantasList); ================================================ FILE: public/pages/examples/components/data/components/summary.js ================================================ class SantasSummary extends HTMLElement { update(list) { const nice = list.filter((item) => item.nice).length; const naughty = list.length - nice; this.innerHTML = list.length ? `

${nice} nice, ${naughty} naughty

` : "

Nobody's on the list yet.

"; } } export const registerSantasSummary = () => customElements.define('santas-summary', SantasSummary); ================================================ FILE: public/pages/examples/components/data/index.css ================================================ body { font-family: 'Iowan Old Style', 'Palatino Linotype', 'URW Palladio L', P052, serif; margin: 1em; } button { font-family: inherit; font-size: 100%; margin-left: 0.5em; } santas-form { display: block } santas-form * { vertical-align: middle; } santas-app h1 { color: darkred; } ================================================ FILE: public/pages/examples/components/data/index.html ================================================ ================================================ FILE: public/pages/examples/components/data/index.js ================================================ import { registerSantasForm } from './components/form.js'; import { registerSantasList } from './components/list.js'; import { registerSantasSummary } from './components/summary.js'; import { registerApp } from './components/app.js'; const app = () => { registerSantasForm(); registerSantasList(); registerSantasSummary(); registerApp(); } document.addEventListener('DOMContentLoaded', app); ================================================ FILE: public/pages/examples/components/shadow-dom/components/avatar.css ================================================ x-avatar { display: flex; align-items: center; justify-content: center; width: 2.5rem; height: 2.5rem; } x-avatar[size=lg] { width: 3.5rem; height: 3.5rem; } x-avatar img { border-radius: 9999px; width: 100%; height: 100%; vertical-align: middle; object-fit: cover; } ================================================ FILE: public/pages/examples/components/shadow-dom/components/avatar.js ================================================ /** * Usage: * * */ class AvatarComponent extends HTMLElement { connectedCallback() { if (!this.querySelector('img')) { this.append(document.createElement('img')); } this.update(); } static get observedAttributes() { return ['src', 'alt']; } attributeChangedCallback() { this.update(); } update() { const img = this.querySelector('img'); if (img) { img.src = this.getAttribute('src'); img.alt = this.getAttribute('alt') || 'avatar'; } } } export const registerAvatarComponent = () => { customElements.define('x-avatar', AvatarComponent); } ================================================ FILE: public/pages/examples/components/shadow-dom/components/badge.css ================================================ x-badge { position: relative; display: inline-flex; flex-shrink: 0; box-sizing: border-box; } x-badge > span.x-badge-label { /* size and position */ box-sizing: inherit; position: absolute; top: 0.2rem; right: 0.2rem; width: 1.25rem; height: 1.25rem; transform: translate(50%, -50%); z-index: 10; /* colors and fonts */ color: white; background-color: rgb(0, 111, 238); border-style: solid; border-color: #333333; border-width: 2px; border-radius: 9999px; font-size: 0.875rem; line-height: 1.2; /* text placement */ display: flex; place-content: center; user-select: none; } ================================================ FILE: public/pages/examples/components/shadow-dom/components/badge.js ================================================ class BadgeComponent extends HTMLElement { #span; connectedCallback() { if (!this.#span) { this.#span = document.createElement('span'); this.#span.className = 'x-badge-label'; } this.insertBefore(this.#span, this.firstChild); this.update(); } update() { if (this.#span) this.#span.textContent = this.getAttribute('content'); } static get observedAttributes() { return ['content']; } attributeChangedCallback() { this.update(); } set content(value) { if (this.getAttribute('content') !== value) { this.setAttribute('content', value); } } get content() { return this.getAttribute('content'); } } export const registerBadgeComponent = () => customElements.define('x-badge', BadgeComponent); ================================================ FILE: public/pages/examples/components/shadow-dom/components/header.css ================================================ @import "../reset.css"; :host { display: block; } header { display: flex; flex-flow: row wrap; justify-content: right; align-items: center; } h1 { font-family: system-ui, sans-serif; margin: 0; display: flex; flex: 1 1 auto; } ::slotted(*) { display: flex; flex: 0 1 auto; } ================================================ FILE: public/pages/examples/components/shadow-dom/components/header.js ================================================ const template = document.createElement('template'); template.innerHTML = `

`; class HeaderComponent extends HTMLElement { constructor() { super(); if (!this.shadowRoot) { this.attachShadow({ mode: 'open' }); this.shadowRoot.append(template.content.cloneNode(true)); } this.update(); } update() { this.shadowRoot.querySelector('h1').textContent = this.getAttribute('title'); } static get observedAttributes() { return ['title']; } attributeChangedCallback() { this.update(); } } export const registerHeaderComponent = () => customElements.define('x-header', HeaderComponent); ================================================ FILE: public/pages/examples/components/shadow-dom/index.css ================================================ @import "./reset.css"; @import "./components/avatar.css"; @import "./components/badge.css"; body { font-family: system-ui, sans-serif; } x-header, main { margin: 1em; padding: 1em; border: 1px dashed black; } ================================================ FILE: public/pages/examples/components/shadow-dom/index.html ================================================

Hello, shadow DOM!

================================================ FILE: public/pages/examples/components/shadow-dom/index.js ================================================ import { registerAvatarComponent } from './components/avatar.js'; import { registerBadgeComponent } from './components/badge.js'; import { registerHeaderComponent } from './components/header.js'; const app = () => { registerAvatarComponent(); registerBadgeComponent(); registerHeaderComponent(); } document.addEventListener('DOMContentLoaded', app); ================================================ FILE: public/pages/examples/components/shadow-dom/reset.css ================================================ /* generic minimal CSS reset inspiration: https://www.digitalocean.com/community/tutorials/css-minimal-css-reset */ :root { box-sizing: border-box; line-height: 1.4; /* https://kilianvalkhof.com/2022/css-html/your-css-reset-needs-text-size-adjust-probably/ */ -moz-text-size-adjust: none; -webkit-text-size-adjust: none; text-size-adjust: none; } *, *:before, *:after { box-sizing: inherit; } body, h1, h2, h3, h4, h5, h6, p { margin: 0; padding: 0; font-weight: normal; } img { max-width:100%; height:auto; } ================================================ FILE: public/pages/examples/components/simple/hello-world.js ================================================ class HelloWorldComponent extends HTMLElement { connectedCallback() { this.textContent = 'hello world!'; } } customElements.define('x-hello-world', HelloWorldComponent); ================================================ FILE: public/pages/examples/components/simple/index.html ================================================

I just want to say...

================================================ FILE: public/pages/examples/sites/importmap/components/metrics.js ================================================ import dayjs from 'dayjs'; import * as webVitals from 'web-vitals'; class MetricsComponent extends HTMLElement { #now = dayjs(); #ttfb; #interval; connectedCallback() { webVitals.onTTFB(_ => this.#ttfb = Math.round(_.value)); this.#interval = setInterval(() => this.update(), 500); } disconnectedCallback() { clearInterval(this.#interval); this.#interval = null; } update() { this.innerHTML = `

Page loaded ${this.#now.fromNow()}, TTFB ${this.#ttfb} milliseconds

`; } } export const registerMetricsComponent = () => { customElements.define('x-metrics', MetricsComponent); } ================================================ FILE: public/pages/examples/sites/importmap/index.css ================================================ body { font-family: sans-serif; } ================================================ FILE: public/pages/examples/sites/importmap/index.html ================================================ ================================================ FILE: public/pages/examples/sites/importmap/index.js ================================================ import { registerMetricsComponent } from './components/metrics.js'; const app = () => { registerMetricsComponent(); }; document.addEventListener('DOMContentLoaded', app); ================================================ FILE: public/pages/examples/sites/importmap/lib/dayjs/module.js ================================================ // UMD version of dayjs, from https://unpkg.com/dayjs/ const dayjs = window.dayjs; const dayjsRelativeTime = window.dayjs_plugin_relativeTime; dayjs.extend(dayjsRelativeTime); export default dayjs; ================================================ FILE: public/pages/examples/sites/importmap/lib/dayjs/relativeTime.js ================================================ !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)}}})); ================================================ FILE: public/pages/examples/sites/importmap/lib/web-vitals.js ================================================ var e,n,t,r,i,o=-1,a=function(e){addEventListener("pageshow",(function(n){n.persisted&&(o=n.timeStamp,e(n))}),!0)},c=function(){var e=self.performance&&performance.getEntriesByType&&performance.getEntriesByType("navigation")[0];if(e&&e.responseStart>0&&e.responseStart=0?r="back-forward-cache":t&&(document.prerendering||u()>0?r="prerender":document.wasDiscarded?r="restore":t.type&&(r=t.type.replace(/_/g,"-")));return{name:e,value:void 0===n?-1:n,rating:"good",delta:0,entries:[],id:"v4-".concat(Date.now(),"-").concat(Math.floor(8999999999999*Math.random())+1e12),navigationType:r}},s=function(e,n,t){try{if(PerformanceObserver.supportedEntryTypes.includes(e)){var r=new PerformanceObserver((function(e){Promise.resolve().then((function(){n(e.getEntries())}))}));return r.observe(Object.assign({type:e,buffered:!0},t||{})),r}}catch(e){}},d=function(e,n,t,r){var i,o;return function(a){n.value>=0&&(a||r)&&((o=n.value-(i||0))||void 0===i)&&(i=n.value,n.delta=o,n.rating=function(e,n){return e>n[1]?"poor":e>n[0]?"needs-improvement":"good"}(n.value,t),e(n))}},l=function(e){requestAnimationFrame((function(){return requestAnimationFrame((function(){return e()}))}))},p=function(e){document.addEventListener("visibilitychange",(function(){"hidden"===document.visibilityState&&e()}))},v=function(e){var n=!1;return function(){n||(e(),n=!0)}},m=-1,h=function(){return"hidden"!==document.visibilityState||document.prerendering?1/0:0},g=function(e){"hidden"===document.visibilityState&&m>-1&&(m="visibilitychange"===e.type?e.timeStamp:0,T())},y=function(){addEventListener("visibilitychange",g,!0),addEventListener("prerenderingchange",g,!0)},T=function(){removeEventListener("visibilitychange",g,!0),removeEventListener("prerenderingchange",g,!0)},E=function(){return m<0&&(m=h(),y(),a((function(){setTimeout((function(){m=h(),y()}),0)}))),{get firstHiddenTime(){return m}}},C=function(e){document.prerendering?addEventListener("prerenderingchange",(function(){return e()}),!0):e()},b=[1800,3e3],S=function(e,n){n=n||{},C((function(){var t,r=E(),i=f("FCP"),o=s("paint",(function(e){e.forEach((function(e){"first-contentful-paint"===e.name&&(o.disconnect(),e.startTimer.value&&(r.value=i,r.entries=o,t())},u=s("layout-shift",c);u&&(t=d(e,r,L,n.reportAllChanges),p((function(){c(u.takeRecords()),t(!0)})),a((function(){i=0,r=f("CLS",0),t=d(e,r,L,n.reportAllChanges),l((function(){return t()}))})),setTimeout(t,0))})))},A=0,I=1/0,P=0,M=function(e){e.forEach((function(e){e.interactionId&&(I=Math.min(I,e.interactionId),P=Math.max(P,e.interactionId),A=P?(P-I)/7+1:0)}))},k=function(){return e?A:performance.interactionCount||0},F=function(){"interactionCount"in performance||e||(e=s("event",M,{type:"event",buffered:!0,durationThreshold:0}))},D=[],x=new Map,R=0,B=function(){var e=Math.min(D.length-1,Math.floor((k()-R)/50));return D[e]},H=[],q=function(e){if(H.forEach((function(n){return n(e)})),e.interactionId||"first-input"===e.entryType){var n=D[D.length-1],t=x.get(e.interactionId);if(t||D.length<10||e.duration>n.latency){if(t)e.duration>t.latency?(t.entries=[e],t.latency=e.duration):e.duration===t.latency&&e.startTime===t.entries[0].startTime&&t.entries.push(e);else{var r={id:e.interactionId,latency:e.duration,entries:[e]};x.set(r.id,r),D.push(r)}D.sort((function(e,n){return n.latency-e.latency})),D.length>10&&D.splice(10).forEach((function(e){return x.delete(e.id)}))}}},O=function(e){var n=self.requestIdleCallback||self.setTimeout,t=-1;return e=v(e),"hidden"===document.visibilityState?e():(t=n(e),p(e)),t},N=[200,500],j=function(e,n){"PerformanceEventTiming"in self&&"interactionId"in PerformanceEventTiming.prototype&&(n=n||{},C((function(){var t;F();var r,i=f("INP"),o=function(e){O((function(){e.forEach(q);var n=B();n&&n.latency!==i.value&&(i.value=n.latency,i.entries=n.entries,r())}))},c=s("event",o,{durationThreshold:null!==(t=n.durationThreshold)&&void 0!==t?t:40});r=d(e,i,N,n.reportAllChanges),c&&(c.observe({type:"first-input",buffered:!0}),p((function(){o(c.takeRecords()),r(!0)})),a((function(){R=k(),D.length=0,x.clear(),i=f("INP"),r=d(e,i,N,n.reportAllChanges)})))})))},_=[2500,4e3],z={},G=function(e,n){n=n||{},C((function(){var t,r=E(),i=f("LCP"),o=function(e){n.reportAllChanges||(e=e.slice(-1)),e.forEach((function(e){e.startTime=0&&t1e12?new Date:performance.now())-e.timeStamp;"pointerdown"==e.type?function(e,n){var t=function(){W(e,n),i()},r=function(){i()},i=function(){removeEventListener("pointerup",t,U),removeEventListener("pointercancel",r,U)};addEventListener("pointerup",t,U),addEventListener("pointercancel",r,U)}(n,e):W(n,e)}},Z=function(e){["mousedown","keydown","touchstart","pointerdown"].forEach((function(n){return e(n,Y,U)}))},$=[100,300],ee=function(e,r){r=r||{},C((function(){var o,c=E(),u=f("FID"),l=function(e){e.startTime this.#ttfb = Math.round(_.value)); this.#interval = setInterval(() => this.update(), 500); } disconnectedCallback() { clearInterval(this.#interval); this.#interval = null; } update() { this.innerHTML = `

Page loaded ${this.#now.fromNow()}, TTFB ${this.#ttfb} milliseconds

`; } } export const registerMetricsComponent = () => { customElements.define('x-metrics', MetricsComponent); } ================================================ FILE: public/pages/examples/sites/imports/index.css ================================================ body { font-family: sans-serif; } ================================================ FILE: public/pages/examples/sites/imports/index.html ================================================ ================================================ FILE: public/pages/examples/sites/imports/index.js ================================================ import { registerMetricsComponent } from './components/metrics.js'; const app = () => { registerMetricsComponent(); }; document.addEventListener('DOMContentLoaded', app); ================================================ FILE: public/pages/examples/sites/imports/lib/dayjs/relativeTime.js ================================================ !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)}}})); ================================================ FILE: public/pages/examples/sites/imports/lib/imports.js ================================================ // UMD version of dayjs, from https://unpkg.com/dayjs/ const dayjs = window.dayjs; const dayjsRelativeTime = window.dayjs_plugin_relativeTime; dayjs.extend(dayjsRelativeTime); // ESM version of web-vitals, from https://unpkg.com/web-vitals/dist/web-vitals.js import * as webVitals from './web-vitals.js'; export { dayjs, webVitals }; ================================================ FILE: public/pages/examples/sites/imports/lib/web-vitals.js ================================================ var e,n,t,r,i,o=-1,a=function(e){addEventListener("pageshow",(function(n){n.persisted&&(o=n.timeStamp,e(n))}),!0)},c=function(){var e=self.performance&&performance.getEntriesByType&&performance.getEntriesByType("navigation")[0];if(e&&e.responseStart>0&&e.responseStart=0?r="back-forward-cache":t&&(document.prerendering||u()>0?r="prerender":document.wasDiscarded?r="restore":t.type&&(r=t.type.replace(/_/g,"-")));return{name:e,value:void 0===n?-1:n,rating:"good",delta:0,entries:[],id:"v4-".concat(Date.now(),"-").concat(Math.floor(8999999999999*Math.random())+1e12),navigationType:r}},s=function(e,n,t){try{if(PerformanceObserver.supportedEntryTypes.includes(e)){var r=new PerformanceObserver((function(e){Promise.resolve().then((function(){n(e.getEntries())}))}));return r.observe(Object.assign({type:e,buffered:!0},t||{})),r}}catch(e){}},d=function(e,n,t,r){var i,o;return function(a){n.value>=0&&(a||r)&&((o=n.value-(i||0))||void 0===i)&&(i=n.value,n.delta=o,n.rating=function(e,n){return e>n[1]?"poor":e>n[0]?"needs-improvement":"good"}(n.value,t),e(n))}},l=function(e){requestAnimationFrame((function(){return requestAnimationFrame((function(){return e()}))}))},p=function(e){document.addEventListener("visibilitychange",(function(){"hidden"===document.visibilityState&&e()}))},v=function(e){var n=!1;return function(){n||(e(),n=!0)}},m=-1,h=function(){return"hidden"!==document.visibilityState||document.prerendering?1/0:0},g=function(e){"hidden"===document.visibilityState&&m>-1&&(m="visibilitychange"===e.type?e.timeStamp:0,T())},y=function(){addEventListener("visibilitychange",g,!0),addEventListener("prerenderingchange",g,!0)},T=function(){removeEventListener("visibilitychange",g,!0),removeEventListener("prerenderingchange",g,!0)},E=function(){return m<0&&(m=h(),y(),a((function(){setTimeout((function(){m=h(),y()}),0)}))),{get firstHiddenTime(){return m}}},C=function(e){document.prerendering?addEventListener("prerenderingchange",(function(){return e()}),!0):e()},b=[1800,3e3],S=function(e,n){n=n||{},C((function(){var t,r=E(),i=f("FCP"),o=s("paint",(function(e){e.forEach((function(e){"first-contentful-paint"===e.name&&(o.disconnect(),e.startTimer.value&&(r.value=i,r.entries=o,t())},u=s("layout-shift",c);u&&(t=d(e,r,L,n.reportAllChanges),p((function(){c(u.takeRecords()),t(!0)})),a((function(){i=0,r=f("CLS",0),t=d(e,r,L,n.reportAllChanges),l((function(){return t()}))})),setTimeout(t,0))})))},A=0,I=1/0,P=0,M=function(e){e.forEach((function(e){e.interactionId&&(I=Math.min(I,e.interactionId),P=Math.max(P,e.interactionId),A=P?(P-I)/7+1:0)}))},k=function(){return e?A:performance.interactionCount||0},F=function(){"interactionCount"in performance||e||(e=s("event",M,{type:"event",buffered:!0,durationThreshold:0}))},D=[],x=new Map,R=0,B=function(){var e=Math.min(D.length-1,Math.floor((k()-R)/50));return D[e]},H=[],q=function(e){if(H.forEach((function(n){return n(e)})),e.interactionId||"first-input"===e.entryType){var n=D[D.length-1],t=x.get(e.interactionId);if(t||D.length<10||e.duration>n.latency){if(t)e.duration>t.latency?(t.entries=[e],t.latency=e.duration):e.duration===t.latency&&e.startTime===t.entries[0].startTime&&t.entries.push(e);else{var r={id:e.interactionId,latency:e.duration,entries:[e]};x.set(r.id,r),D.push(r)}D.sort((function(e,n){return n.latency-e.latency})),D.length>10&&D.splice(10).forEach((function(e){return x.delete(e.id)}))}}},O=function(e){var n=self.requestIdleCallback||self.setTimeout,t=-1;return e=v(e),"hidden"===document.visibilityState?e():(t=n(e),p(e)),t},N=[200,500],j=function(e,n){"PerformanceEventTiming"in self&&"interactionId"in PerformanceEventTiming.prototype&&(n=n||{},C((function(){var t;F();var r,i=f("INP"),o=function(e){O((function(){e.forEach(q);var n=B();n&&n.latency!==i.value&&(i.value=n.latency,i.entries=n.entries,r())}))},c=s("event",o,{durationThreshold:null!==(t=n.durationThreshold)&&void 0!==t?t:40});r=d(e,i,N,n.reportAllChanges),c&&(c.observe({type:"first-input",buffered:!0}),p((function(){o(c.takeRecords()),r(!0)})),a((function(){R=k(),D.length=0,x.clear(),i=f("INP"),r=d(e,i,N,n.reportAllChanges)})))})))},_=[2500,4e3],z={},G=function(e,n){n=n||{},C((function(){var t,r=E(),i=f("LCP"),o=function(e){n.reportAllChanges||(e=e.slice(-1)),e.forEach((function(e){e.startTime=0&&t1e12?new Date:performance.now())-e.timeStamp;"pointerdown"==e.type?function(e,n){var t=function(){W(e,n),i()},r=function(){i()},i=function(){removeEventListener("pointerup",t,U),removeEventListener("pointercancel",r,U)};addEventListener("pointerup",t,U),addEventListener("pointercancel",r,U)}(n,e):W(n,e)}},Z=function(e){["mousedown","keydown","touchstart","pointerdown"].forEach((function(n){return e(n,Y,U)}))},$=[100,300],ee=function(e,r){r=r||{},C((function(){var o,c=E(),u=f("FID"),l=function(e){e.startTime Example
title and navigation ...
main content ...
byline and copyright ...
================================================ FILE: public/pages/examples/sites/page/example2.html ================================================ Example ================================================ FILE: public/pages/examples/sites/page/index.js ================================================ const app = () => { const template = document.querySelector('template#page'); if (template) document.body.appendChild(template.content, true); } document.addEventListener('DOMContentLoaded', app); ================================================ FILE: public/pages/examples/styling/replacing-css-modules/nextjs/layout.tsx ================================================ import styles from './styles.module.css' export default function DashboardLayout({ children, }: { children: React.ReactNode }) { return
{children}
} ================================================ FILE: public/pages/examples/styling/replacing-css-modules/nextjs/styles.module.css ================================================ .dashboard { padding: 24px; } ================================================ FILE: public/pages/examples/styling/replacing-css-modules/vanilla/layout.js ================================================ class Layout extends HTMLElement { constructor() { super(); this.attachShadow({ mode: 'open' }); this.shadowRoot.innerHTML = `
`; } } export const registerLayoutComponent = () => customElements.define('x-layout', Layout); ================================================ FILE: public/pages/examples/styling/replacing-css-modules/vanilla/styles.css ================================================ @import "../shared.css"; .dashboard { padding: 24px; } ================================================ FILE: public/pages/examples/styling/scoping-prefixed/components/example/example.css ================================================ x-example p { font-family: casual, cursive; color: darkblue; } ================================================ FILE: public/pages/examples/styling/scoping-prefixed/components/example/example.js ================================================ class ExampleComponent extends HTMLElement { connectedCallback() { this.innerHTML = '

For example...

'; } } export const registerExampleComponent = () => { customElements.define('x-example', ExampleComponent); } ================================================ FILE: public/pages/examples/styling/scoping-prefixed/components/example/example_nested.css ================================================ x-example { p { font-family: casual, cursive; color: darkblue; } } ================================================ FILE: public/pages/examples/styling/scoping-prefixed/index.css ================================================ @import "./components/example/example.css"; ================================================ FILE: public/pages/examples/styling/scoping-prefixed/index.html ================================================

This <p> is not affected, because it is outside the custom element.

================================================ FILE: public/pages/examples/styling/scoping-prefixed/index.js ================================================ import { registerExampleComponent } from './components/example/example.js'; const app = () => { registerExampleComponent(); } document.addEventListener('DOMContentLoaded', app); ================================================ FILE: public/pages/examples/styling/scoping-shadowed/components/example/example.css ================================================ p { font-family: casual, cursive; color: darkblue; } ================================================ FILE: public/pages/examples/styling/scoping-shadowed/components/example/example.js ================================================ class ExampleComponent extends HTMLElement { constructor() { super(); this.attachShadow({mode: 'open'}); this.shadowRoot.innerHTML = `

For example...

`; } } export const registerExampleComponent = () => { customElements.define('x-example', ExampleComponent); } ================================================ FILE: public/pages/examples/styling/scoping-shadowed/index.html ================================================

This <p> is not affected, even though it is slotted.

================================================ FILE: public/pages/examples/styling/scoping-shadowed/index.js ================================================ import { registerExampleComponent } from './components/example/example.js'; const app = () => { registerExampleComponent(); } document.addEventListener('DOMContentLoaded', app); ================================================ FILE: public/pages/sites.html ================================================ Plain Vanilla - Sites

Sites

#

Pages

For content-driven websites with low interactivity a multi-page approach is best suited.

Abandoning the use of frameworks means writing out those HTML pages from scratch. For this it is important to understand what a good minimal HTML page should look like.

An explanation of each element:

<!doctype html>
The doctype is required to have the HTML parsed as HTML5 instead of an older version.
<html lang="en">
The lang attribute is recommended to make sure browsers don't misdetect the language used in the page.
<head><title>
The title will be used for the browser tab and when bookmarking, making it effectively non-optional.
<head><meta charset="utf-8">
This is borderline unnecessary, but just to make sure a page is properly interpreted as UTF-8 this line should be included. Obviously the editor used to make the page should equally be set to UTF-8.
<head><meta name="viewport">
The viewport meta is necessary to have mobile-friendly layout.
<head><link rel="stylesheet" href="index.css">
By convention the stylesheet is loaded from <head> in a blocking way to ensure the page's markup does not have a flash of unstyled content.
<head><script type="module" src="index.js" defer>
The main JavaScript file is in the <head>, and will bootstrap the web components as explained on the components page. The use of defer allows it to download while the rest of the page is loading and get executed at the end.
<body><noscript>
Because web components don't work without JavaScript it is good practice to include a noscript warning to users that have JavaScript disabled. This warning only needs to be on pages with web components. If you don't want to show anything except the warning, see the template pattern below.
<body><header/main/footer>
The page's markup should be organized using HTML landmarks. Landmarks when used properly help organize the page into logical blocks and make the page's structure accessible. Because they are standards-based, compatibility with present and future accessibility products is more likely.

Pages that should only show their contents if JavaScript is enabled can use this template pattern:

Semantics matter

The markup in the page should default to using semantic HTML, to improve accessibility and SEO. Web components should only be used in those cases where the level of complexity and interaction exceeds the capabilities of plain HTML markup.

Familiarize yourself with these aspects of semantic HTML:

Landmarks
As mentioned above, landmarks are the backbone of a page's structure and deliver good structure and accessibility by default.
Elements
Being familiar with HTML's set of built-in elements saves time, both in avoiding the need for custom elements and in easing implementation of the custom elements that are needed. When properly used HTML elements are accessible by default. Pay special attention to the newly baseline features that enable complex UI patterns with minimal JavaScript, like how popover and dialog can be used to implement HTML-native tooltips, dialogs and menus, and how the details element can be used to implement accordions.
Forms
HTML's built-in forms can implement many interactivity use cases when used to their full extent. Be aware of capabilities like rich input types, client-side validation and UI pseudo classes. When a suitable form input type for a use case cannot be found, consider using form-associated custom elements. To dive into this topic read the article on making a new form control.

Favicons

There is one thing that you will probably want to add to the HTML that is not standards-based and that is a reference to a favicon:

  • To keep it really simple, put a favicon.ico in the root path of the site and link to it from your HTML: <link rel="icon" href="favicon.ico">
  • Consider SVG favicons, but know that Safari does not support them. Embed dark mode in the favicon SVG itself or use a generator like RealFaviconGenerator for more convience.
  • Be aware that because favicons are not based on published web standards it is cumbersome to implement the de facto standard fully.
#

Project

A suggested project layout for a vanilla multi-page website:

/
The project root contains the files that will not be published, such as README.md, LICENSE or .gitignore.
/public
The public folder is published as-is, without build steps. It is the whole website.
/public/index.html
The main landing page of the website, not particularly different from the other pages, except for its path.
/public/index.[js/css]
The main stylesheet and javascript. These contain the shared styles and code for all pages. index.js loads and registers the web components used on all pages. By sharing these across multiple HTML pages unnecessary duplication and inconsistencies between pages can be avoided.
/public/pages/[name].html
All of the site's other pages, each including the same index.js and index.css, and ofcourse containing the content directly as markup in the HTML, leveraging the web components.
/public/components/[name]/
One folder per web component, containing a [name].js and [name].css file. The .js file is imported into the index.js file to register the web component. The .css file is imported into the global index.css or in the shadow DOM, as explained on the styling page.
/public/lib/
For all external libraries used as dependencies. See below for how to add and use these dependencies.
/public/styles/
The global styles referenced from index.css, as explained on the styling page.

Configuration files for a smoother workflow in programmer's editors also belong in the project's root. Most of the development experience of a framework-based project is possible without a build step through editor extensions. See the Visual Studio Code setup for this site for an example.

#

Routing

The old-school routing approach of standard HTML pages and <a> tags to link them together has the advantages of being easily indexed by search engines and fully supporting browser history and bookmarking functionality out of the box. 😉

#

Dependencies

At some point you may want to pull in third-party libraries. Without npm and a bundler this is still possible.

Unpkg

To use libraries without a bundler they need to be prebuilt in either ESM or UMD format. These libraries can be obtained from unpkg.com:

  1. Browse to unpkg.com/[library]/ (trailing slash matters), for example unpkg.com/microlight/
  2. Look for and download the library js file, which may be in a subfolder, like dist, esm or umd
  3. Place the library file in the lib/ folder

Alternatively, the library may be loaded directly from CDN.

UMD

The UMD module format is an older format for libraries loaded from script tag, and it is the most widely supported, especially among older libraries. It can be recognized by having typeof define === 'function' && define.amd somewhere in the library JS.

To include it in your project:

  1. Include it in a script tag: <script src="lib/microlight.js"></script>
  2. Obtain it off the window: const { microlight } = window;

ESM

The ESM module format (also known as JavaScript modules) is the format specified by the ECMAScript standard, and newer or well-behaved libraries will typically provide an ESM build. It can be recognized by the use of the export keyword.

To include it in your project:

  • Load it from CDN:
    import('https://unpkg.com/web-vitals@4.2.2/dist/web-vitals.js').then((webVitals) => ...)
  • Or load it from a local copy:
    import webVitals from 'lib/web-vitals.js'

imports.js

To neatly organize libraries and separate them from the rest of the codebase, they can be loaded and exported from an imports.js file. For example, here is a page that uses a UMD build of Day.js and an ESM build of web-vitals:

The text is rendered by the <x-metrics> component:

In the /lib folder we find these files:

  • web-vitals.js - the ESM build of web-vitals
  • dayjs/
    • dayjs.min.js - the UMD build of Day.js
    • relativeTime.js - the UMD build of this Day.js plugin
  • imports.js

Digging deeper into this last file we see how it bundles loading of third-party dependencies:

It imports the ESM library directly, but it pulls the UMD libraries off the Window object. These are loaded in the HTML.

Here is the combined example:

Regrettably not all libraries have a UMD or ESM build, but more and more do.

Import Maps

An alternative to the imports.js approach are import maps. These define a unique mapping between importable module name and corresponding library file in a special script tag in the HTML head. This allows a more traditional module-based import syntax in the rest of the codebase.

The previous example adapted to use import maps:

Some things to take into account when using import maps:

  • Import maps can only map to ESM modules, so for UMD libraries a wrapper must be provided, as with the module.js wrapper for dayjs in this example.
  • External import maps of the form <script type="importmap" src="importmap.json"> are not yet supported in all browsers. This means the import map must be duplicated in every HTML page.
  • The import map must be defined before the index.js script is loaded, preferably from the <head> section.
  • Import maps can be used to more easily load libraries from a node_modules folder or from a CDN. JSPM generator can be used to quickly create an import map for CDN dependencies. Use this with caution, as adding such external dependencies makes a vanilla codebase rely on the continued availability of that service.
#

Browser support

Vanilla web sites are supported in all modern browsers. But what does that mean?

To keep up with new web standards, keep an eye on these projects:

  • Baseline keeps track of which features are widely available in browsers and lets you know when they are safe to use.
  • Interop is a yearly initiative between browser makers to bring new web platform features to all browsers, or fix compatibility of existing ones. Consider it a preview of what will become baseline.
#

Deploying

Any provider that can host static websites can be used for deployment.

An example using GitHub Pages:

  1. Upload the project as a repository on GitHub
  2. Go to Settings, Pages
  3. Source: GitHub Actions
  4. Static Website, Configure
  5. Scroll down to path, and change it to ./public
  6. Commit changes...
  7. Go to the Actions page for the repository, wait for the site to deploy
#

Testing

Popular testing frameworks are all designed to run in build pipelines. However, a plain vanilla web site has no build. To test web components an old-fashioned approach can be used: testing in the browser using the Mocha framework.

For example, these are the live unit tests for the <x-tab-panel> component used to present tabbed source code panels on this site:

And for ultimate coding inception, here is that tabpanel component showing the testing source code:

Some working notes for how this is set up:

  • The entire code for the unit tests, including the testing libraries, is isolated to a public/tests/ subfolder. The tests will therefore be available live by adding /tests to the deployed site's URL. If you don't want to deploy the tests on the live website, exclude the tests folder during the deploy step.
  • Mocha and Chai are used as test and assertion frameworks, because they work in-browser without a build step.
  • DOM Testing Library is used to more easily query the DOM. The imports-test.js file configures it for vanilla use.
  • An important limitation is that DOM Testing Library cannot query inside shadow roots. To test something inside a shadow root it is necessary to first query for the containing web component, get a handle to its shadowRoot property, and then query inside of that.
  • Web Components initialize asynchronously, which can make them tricky to test. Use the async methods of DOM Testing Library.
#

Example

This website is the example. Check out the project on GitHub.

Up next


Learn how to build single-page applications using vanilla techniques.

Make Applications

================================================ FILE: public/pages/styling.html ================================================ Plain Vanilla - Styling

Styling

Modern CSS

Modern web applications are built on top of rich tooling for dealing with CSS, relying on plenty of NPM packages and build steps. A vanilla web application has to choose a lighter weight path, abandoning the preprocessed modern CSS approaches and choosing strategies that are browser native.

#

Reset

Resetting the styles to a cross-browser common baseline is standard practice in web development, and vanilla web apps are no different.

A minimal reset is the one used by this site:

Other options, in increasing order of complexity:

modern-normalize

A more comprehensive solution for resetting CSS for modern browsers.

Include it from CDN

Kraken

A starting point for front-end projects. It includes a CSS reset, typography, a grid, and other conveniences.

Include it from CDN

Pico CSS

A complete starter kit for styling of semantic HTML that includes a CSS reset.

Include it from CDN

Tailwind

If you're going to be using Tailwind anyway, you may as well lean on its CSS reset.

Include it from CDN

#

Fonts

Typography is the keystone of a web site or web application. A lean approach like vanilla web development should be matched with a lean approach for typography. Modern Font Stacks describes a varied selection of commonly available fonts with good fallbacks, avoiding the need to load custom fonts and add external dependencies.

This site uses the Geometric Humanist stack for normal text, and the Monospace Code stack for source code.

#

The Toolbox

In any real world web project the amount of CSS quickly becomes unwieldy unless it is well-structured. Let's look at the toolbox that CSS provides us in modern browsers to provide that structure.

@import

The most basic structuring technique is separating CSS into multiple files. We could add all those files in order as <link> tags into the index.html but this quickly becomes unworkable if we have multiple HTML pages. Instead it is better to @import them into the index.css where they will still download in parallel.

For example, here's the main CSS file for this site:

Below is a recommended organization of CSS files.

Custom properties (variables)

CSS variables can be used to define the site's font and theme in a central place.

For example, here are the variables for this site:

CSS variables become even more capable when combined with calc().

Custom elements

Styles can be easily scoped to a custom element's tag.

For example, the styles of the avatar component from the components page are all prepended by the x-avatar selector:

Custom elements can also have custom attributes that selectors can leverage, as with the [size=lg] style in this example.

Shadow DOM

Adding a shadow DOM to a web component further isolates its styles from the rest of the page. For example, the x-header component from the previous page styles its h1 element inside its CSS, without affecting the containing page or the header's child elements.

All CSS files that need to apply to the shadow DOM must be loaded into it explicitly, but CSS variables are passed into the shadow DOM.

A limitation of shadow DOMs is that to use custom fonts inside them, they must first be loaded into the light DOM.

#

Files

There are many ways to organize CSS files in a repository, but this is the one used here:

/index.css
The root CSS file that imports all the other ones using @import.
/styles/reset.css
The reset stylesheet is the first thing imported.
/styles/variables.css
All CSS variables are defined in this separate file, including the font system.
/styles/global.css
The global styles that apply across the web pages of the site.
/components/example/example.css
All styles that aren't global are specific to a component, in a CSS file located next to the component's JS file.
#

Scope

In order to avoid conflicting styles between pages and components we want to scope styles locally by default. There are two main ways of achieving that in vanilla web development.

Prefixed selectors

For custom elements that don't have a shadow DOM we can prefix the styles with the tag of the custom element. For example, here's a simple web component that uses prefixed selectors to create a local scope:

Shadow DOM import

Custom elements that use a shadow DOM start out unstyled with a local scope, and all styles must be explicitly imported into them. Here is the prefixed example reworked to use a shadow DOM instead.

To reuse styles from the surrounding page inside the shadow DOM, consider these options:

  • Common CSS files can be imported inside the shadow DOM using <link> tags or @import.
  • CSS variables defined in the surrounding page can be referenced inside the shadow DOM's styles.
  • For advanced shadow domination the ::part pseudo-element can be used to expose an API for styling.
#

Replacing CSS modules

The local scoping feature of CSS modules can be replaced by one of the scoping mechanisms described above. For instance, let's take the example of CSS modules from the Next.JS 14 documentation:

As a vanilla web component, this is what that looks like:

Because the shadow DOM does not inherit the page's styles, the styles.css must first import the styles that are shared between the page and the shadowed web component.

#

Replacing PostCSS

Let's go over the main page of PostCSS to review its feature set.

Add vendor prefixes to CSS rules using values from Can I Use.
Vendor prefixes are no longer needed for most use cases. The :fullscreen pseudo-class shown in the example now works across browsers unprefixed.
Convert modern CSS into something most browsers can understand.
The modern CSS you want to use is most likely already supported. The color: oklch() rule shown in the example now works across browsers.
CSS Modules
See the alternatives described in the previous section.
Enforce consistent conventions and avoid errors in your stylesheets with stylelint.
The vscode-stylelint extension can be added into Visual Studio Code to get the same linting at develop time, without needing it to be baked into a build step.

Bottom line: Microsoft's dropping of support for IE11 combined with the continuing improvements of evergreen browsers has made PostCSS largely unnecessary.

#

Replacing SASS

Similarly to PostCSS, let's go over the main feature set of SASS:

Variables
Replaced by CSS custom properties.
Nesting
CSS nesting is widely supported across major browsers.
Modules
Can be approximated by a combination of @import, CSS variables, and the scoping mechanisms described above.
Mixins
Regrettably the CSS mixins feature that will replace this is still in specification.
Operators
In many cases can be replaced by the built-in calc() feature.

Bottom-line: SASS is a lot more powerful than PostCSS, and while many of its features have a vanilla alternative it is not as easy to replace entirely. YMMV whether the added complexity of the SASS preprocessor is worth the additional abilities.

Up next


Learn about making vanilla sites with web components.

Make Sites

================================================ FILE: public/robots.txt ================================================ User-agent: * Disallow: /blog/generator.html Disallow: /pages/examples/ ================================================ FILE: public/sitemap.txt ================================================ https://plainvanillaweb.com/ https://plainvanillaweb.com/index.html https://plainvanillaweb.com/pages/components.html https://plainvanillaweb.com/pages/styling.html https://plainvanillaweb.com/pages/sites.html https://plainvanillaweb.com/pages/applications.html https://plainvanillaweb.com/blog/index.html https://plainvanillaweb.com/blog/archive.html https://plainvanillaweb.com/blog/articles/2024-08-17-lets-build-a-blog/index.html https://plainvanillaweb.com/blog/articles/2024-08-25-vanilla-entity-encoding/index.html https://plainvanillaweb.com/blog/articles/2024-08-30-poor-mans-signals/index.html https://plainvanillaweb.com/blog/articles/2024-09-03-unix-philosophy/index.html https://plainvanillaweb.com/blog/articles/2024-09-06-how-fast-are-web-components/index.html https://plainvanillaweb.com/blog/articles/2024-09-09-sweet-suspense/index.html https://plainvanillaweb.com/blog/articles/2024-09-16-life-and-times-of-a-custom-element/index.html https://plainvanillaweb.com/blog/articles/2024-09-28-unreasonable-effectiveness-of-vanilla-js/index.html https://plainvanillaweb.com/blog/articles/2024-09-30-lived-experience/index.html https://plainvanillaweb.com/blog/articles/2024-10-07-needs-more-context/index.html https://plainvanillaweb.com/blog/articles/2024-10-20-editing-plain-vanilla/index.html https://plainvanillaweb.com/blog/articles/2024-12-16-caching-vanilla-sites/index.html https://plainvanillaweb.com/blog/articles/2025-01-01-new-years-resolve/index.html https://plainvanillaweb.com/blog/articles/2025-04-21-attribute-property-duality/index.html https://plainvanillaweb.com/blog/articles/2025-05-09-form-control/index.html https://plainvanillaweb.com/blog/articles/2025-06-12-view-transitions/index.html https://plainvanillaweb.com/blog/articles/2025-06-25-routing/index.html https://plainvanillaweb.com/blog/articles/2025-07-13-history-architecture/index.html https://plainvanillaweb.com/blog/articles/2025-07-16-local-first-architecture/index.html https://plainvanillaweb.com/blog/articles/2026-03-01-redesigning-plain-vanilla/index.html https://plainvanillaweb.com/blog/articles/2026-03-09-details-matters/index.html ================================================ FILE: public/styles/global.css ================================================ html { margin: 0 auto; max-width: 960px; background-color: var(--background-color-html); box-shadow: 0 0 0.5em var(--background-color-html-shadow); color: var(--text-color); overscroll-behavior: none; } body { font-family: var(--font-system); font-weight: normal; background-color: var(--background-color); padding: var(--body-padding-tb) var(--body-padding-lr); padding-bottom: 0; /* make room for navigation */ padding-top: calc(var(--body-padding-tb) + 5em); } a, a:hover { color: var(--link-color); } h1 { font-size: 3.5em; position: relative; left: -5px; font-weight: lighter; } h2, h3, h4, p { margin-bottom: 1em; } h2 { margin-top: 1em; font-size: 1.5em; font-weight: lighter; } h3 { margin-top: 1em; font-size: 1.2em; } h4 { font-size: 1em; } ul { padding-left: 2em; margin-top: 0.5em; } ul ul { margin-top: 0; } dl { padding-left: 0; } dt { margin: 0.5em 0; } dt::before { content: ""; border-left: 3px solid var(--base-color); margin: 0 calc(1em - 3px) 0 0; } dd { margin-left: 1em; margin-bottom: 1em; } hr { border: none; border-top: 1px solid var(--border-color); margin: -0.5em 0 1em 0; width: 100%; } section { position: relative; } a.section-anchor { color: inherit; display: none; position: absolute; left: -1.5em; padding-left: 0.6em; font-size: 120%; top: 0; padding-top: 0.2em; width: 1.5em; height: 3em; opacity: 0.4; } section:hover > a.section-anchor { display: inline; } section:hover > a.section-anchor:hover { opacity: 1; color: var(--link-color); } blockquote { border-left: 10px solid var(--base-color-tint-light); margin: 1.5em 0 1em 0.5em; padding: 0.5em 10px; quotes: "\201C""\201D""\2018""\2019"; color: var(--text-color-mute); font-size: 90%; } cite { display: block; margin: 0 0 1em 0.5em; } table { margin-bottom: 1em; } /* page header and navigation */ header { margin-bottom: 1.5em; text-align: left; } nav { position: fixed; border: none; top: 0; left: 0; right: 0; width: 100%; max-width: 960px; margin: 0 auto; padding: 3em 0 0 0; /* make content scroll underneath it */ background-color: var(--background-color); z-index: 1; box-shadow: 0 1em 1em var(--background-color); clip-path: inset(0px 0 -2em 0); } nav ol { display: flex; margin: 0 var(--body-padding-lr); list-style-type: none; padding: 0.7em 0; border-bottom: 1px solid black; } nav ol a { text-decoration: none; color: inherit; } nav ol a:hover { text-decoration: underline; color: inherit; } nav ol a[aria-current], nav ol a[aria-current]:hover { font-weight: bold; text-decoration: none; } nav ol li { display: inline; } nav ol li::before { content: ""; border-left: 1px solid black; margin: 0 0.4em; } nav ol li:first-child::before { display:none; } nav ol li.nav-right { margin-left: auto; } nav ol li.nav-right::before { display: none; } /* responsive nav menu and other content on smaller screens */ @media screen and (max-width: 600px) { body { padding-top: var(--body-padding-tb); } h1 { font-size: 3em; } nav[popover] { top: 4em; right: 1em; padding-top: 1em; background-color: color-mix(in srgb, var(--background-color) 90%, transparent); } button[popovertarget] { position: absolute; top: 0; right: 0.5em; padding: 0.5em; font-size: 2em; background: none; color: inherit; border: none; } nav ol { flex-direction: column; } nav ol li { text-align: right; font-size: 1.4em; } nav ol li::before { border-left: none; margin: 0; } nav ol li.nav-right { margin-left: 0; } } @media screen and (min-width: 601px) { nav[popover] { display: block; } button[popovertarget] { display: none; } } /* (hidden) skip to content link */ a.skip-to-content { /* from https://www.a11y-collective.com/blog/skip-to-main-content/ */ position: absolute; left: -9999px; z-index: 999; padding: 1em; background-color: black; color: white; opacity: 0; } a.skip-to-content:focus { left: 50%; transform: translateX(-50%); opacity: 1; } main[id] { /* take sticky header into account when scrolling via skip content */ scroll-margin-top: 8em; } /* code examples */ code { color: var(--code-text-color); background-color: var(--code-text-color-bg); overflow-wrap: break-word; font-family: var(--font-system-code); font-size: var(--font-system-code-size); } x-code-viewer { border: 1px solid var(--border-color); margin: 1em 0 1.5em 0; min-height: 6em; max-height: 30em; } x-tab-panel { border: 1px solid var(--border-color); margin: 1em 0 1.5em 0; min-height: 8em; } x-tab-panel x-code-viewer { border: none; margin: 0; } iframe { display: block; width: 100%; margin: 1em 0; } /* asides */ aside { position: relative; background-color: var(--background-color-mute); text-align: left; margin: 3em 0 3em calc(var(--body-padding-lr) * -1); padding: 3em 2em 3em var(--body-padding-lr); } aside.aside-mirror { margin: 3em calc(var(--body-padding-lr) * -1) 3em 0; padding: 3em var(--body-padding-lr) 3em 2em; text-align: right; } aside h2, aside h3, aside h4 { margin: 0; } aside p { margin: 1em 0 0.5em 0; } aside x-code-viewer { margin: 1em 0; } /* page footer */ footer { margin: 4em calc(var(--body-padding-lr) * -1); padding: 3em var(--body-padding-lr); margin-bottom: 0; background-color: var(--background-color-invert); color: var(--text-color-invert); text-align: right; } footer .top-link { margin-top: 1.5em; font-size: 0.9em; } footer a { color: inherit; text-decoration: none; } footer a:hover { text-decoration: underline; color: inherit; } footer .contact a::before { font-size: 0.9em; content: ""; border-left: 1px solid var(--text-color-invert); margin: 0 0.4em 0 0.2em; } footer .contact a:first-child::before { display:none; } /* utilities */ .hero-text { font-size: 1.2em; line-height: 1.9em; margin-bottom: 1.8em; } a.button { display: inline-block; font-size: 1.1em; color: var(--base-color-shade-darker); background-color: var(--base-color); margin-top: 0.5em; padding: 0.5em 3em; border-radius: 5px; box-shadow: 0.1em 0.1em 0.2em -0.1em var(--background-color-html); text-decoration: none; } a.button:hover { background-color: var(--base-color-tint-light); } ================================================ FILE: public/styles/reset.css ================================================ /* generic minimal CSS reset inspiration: https://www.digitalocean.com/community/tutorials/css-minimal-css-reset */ :root { box-sizing: border-box; line-height: 1.6; /* https://kilianvalkhof.com/2022/css-html/your-css-reset-needs-text-size-adjust-probably/ */ -moz-text-size-adjust: none; -webkit-text-size-adjust: none; text-size-adjust: none; } *, *::before, *::after { box-sizing: inherit; } body, h1, h2, h3, h4, h5, h6, p { margin: 0; padding: 0; font-weight: normal; } img { max-width:100%; height:auto; } ================================================ FILE: public/styles/variables.css ================================================ :root { /* https://modernfontstacks.com/ geometric humanist font */ --font-system: Avenir, Montserrat, Corbel, source-sans-pro, sans-serif; /* monospace code font */ --font-system-code: ui-monospace, 'Cascadia Code', 'Source Code Pro', Menlo, Consolas, 'DejaVu Sans Mono', monospace; --font-system-code-size: 0.8rem; --base-color: #FFCC4D; --base-color-tint-light: #ffdb82; --base-color-tint-lighter: #fff5db; --base-color-shade-dark: #b38f36; --base-color-shade-darker: #191408; --background-color-html: #969696; --background-color-html-shadow: #404040; --background-color: #fdfdfd; --background-color-mute: #f2f2f2; --text-color: black; --text-color-mute: hsl(0, 0%, 40%); --background-color-invert: var(--base-color-shade-darker); --text-color-invert: white; --link-color: black; --border-color: black; --code-text-color: var(--text-color); --code-text-color-bg: inherit; --panel-title-color: black; --panel-title-color-bg: var(--base-color-tint-lighter); --body-padding-lr: 5em; --body-padding-tb: 3em; } @media screen and (max-width: 600px) { :root { --body-padding-lr: 3em; --body-padding-tb: 3em; } } ================================================ FILE: public/tests/imports-test.js ================================================ const { expect } = window.chai; const { getByText, queries, within, waitFor, fireEvent } = window.TestingLibraryDom; let rootContainer; let screen; beforeEach(() => { // the hidden div where the test can render elements rootContainer = document.createElement("div"); rootContainer.style.position = 'absolute'; rootContainer.style.left = '-10000px'; document.body.appendChild(rootContainer); // pre-bind @testing-library/dom helpers to rootContainer screen = Object.keys(queries).reduce((helpers, key) => { const fn = queries[key] helpers[key] = fn.bind(null, rootContainer) return helpers }, {}); }); afterEach(() => { document.body.removeChild(rootContainer); rootContainer = null; }); function render(el) { rootContainer.appendChild(el); } export { rootContainer, expect, render, getByText, screen, within, waitFor, fireEvent }; ================================================ FILE: public/tests/index.html ================================================ Plain Vanilla - Tests
================================================ FILE: public/tests/index.js ================================================ import { registerTabPanelComponent } from "../components/tab-panel/tab-panel.js"; const app = () => { registerTabPanelComponent(); mocha.run(); } document.addEventListener('DOMContentLoaded', app); ================================================ FILE: public/tests/lib/@testing-library/dom.umd.js ================================================ (function (global, factory) { typeof exports === 'object' && typeof module !== 'undefined' ? factory(exports) : typeof define === 'function' && define.amd ? define(['exports'], factory) : (global = typeof globalThis !== 'undefined' ? globalThis : global || self, factory(global.TestingLibraryDom = {})); })(this, (function (exports) { 'use strict'; function _mergeNamespaces(n, m) { m.forEach(function (e) { e && typeof e !== 'string' && !Array.isArray(e) && Object.keys(e).forEach(function (k) { if (k !== 'default' && !(k in n)) { var d = Object.getOwnPropertyDescriptor(e, k); Object.defineProperty(n, k, d.get ? d : { enumerable: true, get: function () { return e[k]; } }); } }); }); return Object.freeze(n); } var build = {}; var ansiStyles = {exports: {}}; (function (module) { const ANSI_BACKGROUND_OFFSET = 10; const wrapAnsi256 = function (offset) { if (offset === void 0) { offset = 0; } return code => "\x1B[" + (38 + offset) + ";5;" + code + "m"; }; const wrapAnsi16m = function (offset) { if (offset === void 0) { offset = 0; } return (red, green, blue) => "\x1B[" + (38 + offset) + ";2;" + red + ";" + green + ";" + blue + "m"; }; function assembleStyles() { const codes = new Map(); const styles = { modifier: { reset: [0, 0], // 21 isn't widely supported and 22 does the same thing bold: [1, 22], dim: [2, 22], italic: [3, 23], underline: [4, 24], overline: [53, 55], inverse: [7, 27], hidden: [8, 28], strikethrough: [9, 29] }, color: { black: [30, 39], red: [31, 39], green: [32, 39], yellow: [33, 39], blue: [34, 39], magenta: [35, 39], cyan: [36, 39], white: [37, 39], // Bright color blackBright: [90, 39], redBright: [91, 39], greenBright: [92, 39], yellowBright: [93, 39], blueBright: [94, 39], magentaBright: [95, 39], cyanBright: [96, 39], whiteBright: [97, 39] }, bgColor: { bgBlack: [40, 49], bgRed: [41, 49], bgGreen: [42, 49], bgYellow: [43, 49], bgBlue: [44, 49], bgMagenta: [45, 49], bgCyan: [46, 49], bgWhite: [47, 49], // Bright color bgBlackBright: [100, 49], bgRedBright: [101, 49], bgGreenBright: [102, 49], bgYellowBright: [103, 49], bgBlueBright: [104, 49], bgMagentaBright: [105, 49], bgCyanBright: [106, 49], bgWhiteBright: [107, 49] } }; // Alias bright black as gray (and grey) styles.color.gray = styles.color.blackBright; styles.bgColor.bgGray = styles.bgColor.bgBlackBright; styles.color.grey = styles.color.blackBright; styles.bgColor.bgGrey = styles.bgColor.bgBlackBright; for (const [groupName, group] of Object.entries(styles)) { for (const [styleName, style] of Object.entries(group)) { styles[styleName] = { open: "\x1B[" + style[0] + "m", close: "\x1B[" + style[1] + "m" }; group[styleName] = styles[styleName]; codes.set(style[0], style[1]); } Object.defineProperty(styles, groupName, { value: group, enumerable: false }); } Object.defineProperty(styles, 'codes', { value: codes, enumerable: false }); styles.color.close = '\u001B[39m'; styles.bgColor.close = '\u001B[49m'; styles.color.ansi256 = wrapAnsi256(); styles.color.ansi16m = wrapAnsi16m(); styles.bgColor.ansi256 = wrapAnsi256(ANSI_BACKGROUND_OFFSET); styles.bgColor.ansi16m = wrapAnsi16m(ANSI_BACKGROUND_OFFSET); // From https://github.com/Qix-/color-convert/blob/3f0e0d4e92e235796ccb17f6e85c72094a651f49/conversions.js Object.defineProperties(styles, { rgbToAnsi256: { value: (red, green, blue) => { // We use the extended greyscale palette here, with the exception of // black and white. normal palette only has 4 greyscale shades. if (red === green && green === blue) { if (red < 8) { return 16; } if (red > 248) { return 231; } return Math.round((red - 8) / 247 * 24) + 232; } return 16 + 36 * Math.round(red / 255 * 5) + 6 * Math.round(green / 255 * 5) + Math.round(blue / 255 * 5); }, enumerable: false }, hexToRgb: { value: hex => { const matches = /(?[a-f\d]{6}|[a-f\d]{3})/i.exec(hex.toString(16)); if (!matches) { return [0, 0, 0]; } let { colorString } = matches.groups; if (colorString.length === 3) { colorString = colorString.split('').map(character => character + character).join(''); } const integer = Number.parseInt(colorString, 16); return [integer >> 16 & 0xFF, integer >> 8 & 0xFF, integer & 0xFF]; }, enumerable: false }, hexToAnsi256: { value: hex => styles.rgbToAnsi256(...styles.hexToRgb(hex)), enumerable: false } }); return styles; } // Make the export immutable Object.defineProperty(module, 'exports', { enumerable: true, get: assembleStyles }); })(ansiStyles); var collections = {}; Object.defineProperty(collections, '__esModule', { value: true }); collections.printIteratorEntries = printIteratorEntries; collections.printIteratorValues = printIteratorValues; collections.printListItems = printListItems; collections.printObjectProperties = printObjectProperties; /** * Copyright (c) Facebook, Inc. and its affiliates. All Rights Reserved. * * This source code is licensed under the MIT license found in the * LICENSE file in the root directory of this source tree. * */ const getKeysOfEnumerableProperties = (object, compareKeys) => { const keys = Object.keys(object).sort(compareKeys); if (Object.getOwnPropertySymbols) { Object.getOwnPropertySymbols(object).forEach(symbol => { if (Object.getOwnPropertyDescriptor(object, symbol).enumerable) { keys.push(symbol); } }); } return keys; }; /** * Return entries (for example, of a map) * with spacing, indentation, and comma * without surrounding punctuation (for example, braces) */ function printIteratorEntries(iterator, config, indentation, depth, refs, printer, // Too bad, so sad that separator for ECMAScript Map has been ' => ' // What a distracting diff if you change a data structure to/from // ECMAScript Object or Immutable.Map/OrderedMap which use the default. separator) { if (separator === void 0) { separator = ': '; } let result = ''; let current = iterator.next(); if (!current.done) { result += config.spacingOuter; const indentationNext = indentation + config.indent; while (!current.done) { const name = printer(current.value[0], config, indentationNext, depth, refs); const value = printer(current.value[1], config, indentationNext, depth, refs); result += indentationNext + name + separator + value; current = iterator.next(); if (!current.done) { result += ',' + config.spacingInner; } else if (!config.min) { result += ','; } } result += config.spacingOuter + indentation; } return result; } /** * Return values (for example, of a set) * with spacing, indentation, and comma * without surrounding punctuation (braces or brackets) */ function printIteratorValues(iterator, config, indentation, depth, refs, printer) { let result = ''; let current = iterator.next(); if (!current.done) { result += config.spacingOuter; const indentationNext = indentation + config.indent; while (!current.done) { result += indentationNext + printer(current.value, config, indentationNext, depth, refs); current = iterator.next(); if (!current.done) { result += ',' + config.spacingInner; } else if (!config.min) { result += ','; } } result += config.spacingOuter + indentation; } return result; } /** * Return items (for example, of an array) * with spacing, indentation, and comma * without surrounding punctuation (for example, brackets) **/ function printListItems(list, config, indentation, depth, refs, printer) { let result = ''; if (list.length) { result += config.spacingOuter; const indentationNext = indentation + config.indent; for (let i = 0; i < list.length; i++) { result += indentationNext; if (i in list) { result += printer(list[i], config, indentationNext, depth, refs); } if (i < list.length - 1) { result += ',' + config.spacingInner; } else if (!config.min) { result += ','; } } result += config.spacingOuter + indentation; } return result; } /** * Return properties of an object * with spacing, indentation, and comma * without surrounding punctuation (for example, braces) */ function printObjectProperties(val, config, indentation, depth, refs, printer) { let result = ''; const keys = getKeysOfEnumerableProperties(val, config.compareKeys); if (keys.length) { result += config.spacingOuter; const indentationNext = indentation + config.indent; for (let i = 0; i < keys.length; i++) { const key = keys[i]; const name = printer(key, config, indentationNext, depth, refs); const value = printer(val[key], config, indentationNext, depth, refs); result += indentationNext + name + ': ' + value; if (i < keys.length - 1) { result += ',' + config.spacingInner; } else if (!config.min) { result += ','; } } result += config.spacingOuter + indentation; } return result; } var AsymmetricMatcher = {}; Object.defineProperty(AsymmetricMatcher, '__esModule', { value: true }); AsymmetricMatcher.test = AsymmetricMatcher.serialize = AsymmetricMatcher.default = void 0; var _collections$3 = collections; var global$1 = function () { if (typeof globalThis !== 'undefined') { return globalThis; } else if (typeof global$1 !== 'undefined') { return global$1; } else if (typeof self !== 'undefined') { return self; } else if (typeof window !== 'undefined') { return window; } else { return Function('return this')(); } }(); var Symbol$2 = global$1['jest-symbol-do-not-touch'] || global$1.Symbol; const asymmetricMatcher = typeof Symbol$2 === 'function' && Symbol$2.for ? Symbol$2.for('jest.asymmetricMatcher') : 0x1357a5; const SPACE$2 = ' '; const serialize$6 = (val, config, indentation, depth, refs, printer) => { const stringedValue = val.toString(); if (stringedValue === 'ArrayContaining' || stringedValue === 'ArrayNotContaining') { if (++depth > config.maxDepth) { return '[' + stringedValue + ']'; } return stringedValue + SPACE$2 + '[' + (0, _collections$3.printListItems)(val.sample, config, indentation, depth, refs, printer) + ']'; } if (stringedValue === 'ObjectContaining' || stringedValue === 'ObjectNotContaining') { if (++depth > config.maxDepth) { return '[' + stringedValue + ']'; } return stringedValue + SPACE$2 + '{' + (0, _collections$3.printObjectProperties)(val.sample, config, indentation, depth, refs, printer) + '}'; } if (stringedValue === 'StringMatching' || stringedValue === 'StringNotMatching') { return stringedValue + SPACE$2 + printer(val.sample, config, indentation, depth, refs); } if (stringedValue === 'StringContaining' || stringedValue === 'StringNotContaining') { return stringedValue + SPACE$2 + printer(val.sample, config, indentation, depth, refs); } return val.toAsymmetricMatcher(); }; AsymmetricMatcher.serialize = serialize$6; const test$6 = val => val && val.$$typeof === asymmetricMatcher; AsymmetricMatcher.test = test$6; const plugin$6 = { serialize: serialize$6, test: test$6 }; var _default$2k = plugin$6; AsymmetricMatcher.default = _default$2k; var ConvertAnsi = {}; var ansiRegex = function (_temp) { let { onlyFirst = false } = _temp === void 0 ? {} : _temp; const pattern = ['[\\u001B\\u009B][[\\]()#;?]*(?:(?:(?:(?:;[-a-zA-Z\\d\\/#&.:=?%@~_]+)*|[a-zA-Z\\d]+(?:;[-a-zA-Z\\d\\/#&.:=?%@~_]*)*)?\\u0007)', '(?:(?:\\d{1,4}(?:;\\d{0,4})*)?[\\dA-PR-TZcf-ntqry=><~]))'].join('|'); return new RegExp(pattern, onlyFirst ? undefined : 'g'); }; Object.defineProperty(ConvertAnsi, '__esModule', { value: true }); ConvertAnsi.test = ConvertAnsi.serialize = ConvertAnsi.default = void 0; var _ansiRegex = _interopRequireDefault$9(ansiRegex); var _ansiStyles$1 = _interopRequireDefault$9(ansiStyles.exports); function _interopRequireDefault$9(obj) { return obj && obj.__esModule ? obj : { default: obj }; } /** * Copyright (c) Facebook, Inc. and its affiliates. All Rights Reserved. * * This source code is licensed under the MIT license found in the * LICENSE file in the root directory of this source tree. */ const toHumanReadableAnsi = text => text.replace((0, _ansiRegex.default)(), match => { switch (match) { case _ansiStyles$1.default.red.close: case _ansiStyles$1.default.green.close: case _ansiStyles$1.default.cyan.close: case _ansiStyles$1.default.gray.close: case _ansiStyles$1.default.white.close: case _ansiStyles$1.default.yellow.close: case _ansiStyles$1.default.bgRed.close: case _ansiStyles$1.default.bgGreen.close: case _ansiStyles$1.default.bgYellow.close: case _ansiStyles$1.default.inverse.close: case _ansiStyles$1.default.dim.close: case _ansiStyles$1.default.bold.close: case _ansiStyles$1.default.reset.open: case _ansiStyles$1.default.reset.close: return ''; case _ansiStyles$1.default.red.open: return ''; case _ansiStyles$1.default.green.open: return ''; case _ansiStyles$1.default.cyan.open: return ''; case _ansiStyles$1.default.gray.open: return ''; case _ansiStyles$1.default.white.open: return ''; case _ansiStyles$1.default.yellow.open: return ''; case _ansiStyles$1.default.bgRed.open: return ''; case _ansiStyles$1.default.bgGreen.open: return ''; case _ansiStyles$1.default.bgYellow.open: return ''; case _ansiStyles$1.default.inverse.open: return ''; case _ansiStyles$1.default.dim.open: return ''; case _ansiStyles$1.default.bold.open: return ''; default: return ''; } }); const test$5 = val => typeof val === 'string' && !!val.match((0, _ansiRegex.default)()); ConvertAnsi.test = test$5; const serialize$5 = (val, config, indentation, depth, refs, printer) => printer(toHumanReadableAnsi(val), config, indentation, depth, refs); ConvertAnsi.serialize = serialize$5; const plugin$5 = { serialize: serialize$5, test: test$5 }; var _default$2j = plugin$5; ConvertAnsi.default = _default$2j; var DOMCollection$1 = {}; Object.defineProperty(DOMCollection$1, '__esModule', { value: true }); DOMCollection$1.test = DOMCollection$1.serialize = DOMCollection$1.default = void 0; var _collections$2 = collections; /** * Copyright (c) Facebook, Inc. and its affiliates. All Rights Reserved. * * This source code is licensed under the MIT license found in the * LICENSE file in the root directory of this source tree. */ /* eslint-disable local/ban-types-eventually */ const SPACE$1 = ' '; const OBJECT_NAMES = ['DOMStringMap', 'NamedNodeMap']; const ARRAY_REGEXP = /^(HTML\w*Collection|NodeList)$/; const testName = name => OBJECT_NAMES.indexOf(name) !== -1 || ARRAY_REGEXP.test(name); const test$4 = val => val && val.constructor && !!val.constructor.name && testName(val.constructor.name); DOMCollection$1.test = test$4; const isNamedNodeMap = collection => collection.constructor.name === 'NamedNodeMap'; const serialize$4 = (collection, config, indentation, depth, refs, printer) => { const name = collection.constructor.name; if (++depth > config.maxDepth) { return '[' + name + ']'; } return (config.min ? '' : name + SPACE$1) + (OBJECT_NAMES.indexOf(name) !== -1 ? '{' + (0, _collections$2.printObjectProperties)(isNamedNodeMap(collection) ? Array.from(collection).reduce((props, attribute) => { props[attribute.name] = attribute.value; return props; }, {}) : { ...collection }, config, indentation, depth, refs, printer) + '}' : '[' + (0, _collections$2.printListItems)(Array.from(collection), config, indentation, depth, refs, printer) + ']'); }; DOMCollection$1.serialize = serialize$4; const plugin$4 = { serialize: serialize$4, test: test$4 }; var _default$2i = plugin$4; DOMCollection$1.default = _default$2i; var DOMElement = {}; var markup = {}; var escapeHTML$2 = {}; Object.defineProperty(escapeHTML$2, '__esModule', { value: true }); escapeHTML$2.default = escapeHTML$1; /** * Copyright (c) Facebook, Inc. and its affiliates. All Rights Reserved. * * This source code is licensed under the MIT license found in the * LICENSE file in the root directory of this source tree. */ function escapeHTML$1(str) { return str.replace(//g, '>'); } Object.defineProperty(markup, '__esModule', { value: true }); markup.printText = markup.printProps = markup.printElementAsLeaf = markup.printElement = markup.printComment = markup.printChildren = void 0; var _escapeHTML = _interopRequireDefault$8(escapeHTML$2); function _interopRequireDefault$8(obj) { return obj && obj.__esModule ? obj : { default: obj }; } /** * Copyright (c) Facebook, Inc. and its affiliates. All Rights Reserved. * * This source code is licensed under the MIT license found in the * LICENSE file in the root directory of this source tree. */ // Return empty string if keys is empty. const printProps$1 = (keys, props, config, indentation, depth, refs, printer) => { const indentationNext = indentation + config.indent; const colors = config.colors; return keys.map(key => { const value = props[key]; let printed = printer(value, config, indentationNext, depth, refs); if (typeof value !== 'string') { if (printed.indexOf('\n') !== -1) { printed = config.spacingOuter + indentationNext + printed + config.spacingOuter + indentation; } printed = '{' + printed + '}'; } return config.spacingInner + indentation + colors.prop.open + key + colors.prop.close + '=' + colors.value.open + printed + colors.value.close; }).join(''); }; // Return empty string if children is empty. markup.printProps = printProps$1; const printChildren$1 = (children, config, indentation, depth, refs, printer) => children.map(child => config.spacingOuter + indentation + (typeof child === 'string' ? printText$1(child, config) : printer(child, config, indentation, depth, refs))).join(''); markup.printChildren = printChildren$1; const printText$1 = (text, config) => { const contentColor = config.colors.content; return contentColor.open + (0, _escapeHTML.default)(text) + contentColor.close; }; markup.printText = printText$1; const printComment$1 = (comment, config) => { const commentColor = config.colors.comment; return commentColor.open + '' + commentColor.close; }; // Separate the functions to format props, children, and element, // so a plugin could override a particular function, if needed. // Too bad, so sad: the traditional (but unnecessary) space // in a self-closing tagColor requires a second test of printedProps. markup.printComment = printComment$1; const printElement$1 = (type, printedProps, printedChildren, config, indentation) => { const tagColor = config.colors.tag; return tagColor.open + '<' + type + (printedProps && tagColor.close + printedProps + config.spacingOuter + indentation + tagColor.open) + (printedChildren ? '>' + tagColor.close + printedChildren + config.spacingOuter + indentation + tagColor.open + '' + tagColor.close; }; markup.printElement = printElement$1; const printElementAsLeaf$1 = (type, config) => { const tagColor = config.colors.tag; return tagColor.open + '<' + type + tagColor.close + ' …' + tagColor.open + ' />' + tagColor.close; }; markup.printElementAsLeaf = printElementAsLeaf$1; Object.defineProperty(DOMElement, '__esModule', { value: true }); DOMElement.test = DOMElement.serialize = DOMElement.default = void 0; var _markup$2 = markup; /** * Copyright (c) Facebook, Inc. and its affiliates. All Rights Reserved. * * This source code is licensed under the MIT license found in the * LICENSE file in the root directory of this source tree. */ const ELEMENT_NODE$2 = 1; const TEXT_NODE$2 = 3; const COMMENT_NODE$2 = 8; const FRAGMENT_NODE$1 = 11; const ELEMENT_REGEXP$1 = /^((HTML|SVG)\w*)?Element$/; const testHasAttribute = val => { try { return typeof val.hasAttribute === 'function' && val.hasAttribute('is'); } catch { return false; } }; const testNode$1 = val => { const constructorName = val.constructor.name; const { nodeType, tagName } = val; const isCustomElement = typeof tagName === 'string' && tagName.includes('-') || testHasAttribute(val); return nodeType === ELEMENT_NODE$2 && (ELEMENT_REGEXP$1.test(constructorName) || isCustomElement) || nodeType === TEXT_NODE$2 && constructorName === 'Text' || nodeType === COMMENT_NODE$2 && constructorName === 'Comment' || nodeType === FRAGMENT_NODE$1 && constructorName === 'DocumentFragment'; }; const test$3 = val => { var _val$constructor; return (val === null || val === void 0 ? void 0 : (_val$constructor = val.constructor) === null || _val$constructor === void 0 ? void 0 : _val$constructor.name) && testNode$1(val); }; DOMElement.test = test$3; function nodeIsText$1(node) { return node.nodeType === TEXT_NODE$2; } function nodeIsComment$1(node) { return node.nodeType === COMMENT_NODE$2; } function nodeIsFragment$1(node) { return node.nodeType === FRAGMENT_NODE$1; } const serialize$3 = (node, config, indentation, depth, refs, printer) => { if (nodeIsText$1(node)) { return (0, _markup$2.printText)(node.data, config); } if (nodeIsComment$1(node)) { return (0, _markup$2.printComment)(node.data, config); } const type = nodeIsFragment$1(node) ? 'DocumentFragment' : node.tagName.toLowerCase(); if (++depth > config.maxDepth) { return (0, _markup$2.printElementAsLeaf)(type, config); } return (0, _markup$2.printElement)(type, (0, _markup$2.printProps)(nodeIsFragment$1(node) ? [] : Array.from(node.attributes).map(attr => attr.name).sort(), nodeIsFragment$1(node) ? {} : Array.from(node.attributes).reduce((props, attribute) => { props[attribute.name] = attribute.value; return props; }, {}), config, indentation + config.indent, depth, refs, printer), (0, _markup$2.printChildren)(Array.prototype.slice.call(node.childNodes || node.children), config, indentation + config.indent, depth, refs, printer), config, indentation); }; DOMElement.serialize = serialize$3; const plugin$3 = { serialize: serialize$3, test: test$3 }; var _default$2h = plugin$3; DOMElement.default = _default$2h; var Immutable = {}; Object.defineProperty(Immutable, '__esModule', { value: true }); Immutable.test = Immutable.serialize = Immutable.default = void 0; var _collections$1 = collections; /** * Copyright (c) Facebook, Inc. and its affiliates. All Rights Reserved. * * This source code is licensed under the MIT license found in the * LICENSE file in the root directory of this source tree. */ // SENTINEL constants are from https://github.com/facebook/immutable-js const IS_ITERABLE_SENTINEL = '@@__IMMUTABLE_ITERABLE__@@'; const IS_LIST_SENTINEL = '@@__IMMUTABLE_LIST__@@'; const IS_KEYED_SENTINEL = '@@__IMMUTABLE_KEYED__@@'; const IS_MAP_SENTINEL = '@@__IMMUTABLE_MAP__@@'; const IS_ORDERED_SENTINEL = '@@__IMMUTABLE_ORDERED__@@'; const IS_RECORD_SENTINEL = '@@__IMMUTABLE_RECORD__@@'; // immutable v4 const IS_SEQ_SENTINEL = '@@__IMMUTABLE_SEQ__@@'; const IS_SET_SENTINEL = '@@__IMMUTABLE_SET__@@'; const IS_STACK_SENTINEL = '@@__IMMUTABLE_STACK__@@'; const getImmutableName = name => 'Immutable.' + name; const printAsLeaf = name => '[' + name + ']'; const SPACE = ' '; const LAZY = '…'; // Seq is lazy if it calls a method like filter const printImmutableEntries = (val, config, indentation, depth, refs, printer, type) => ++depth > config.maxDepth ? printAsLeaf(getImmutableName(type)) : getImmutableName(type) + SPACE + '{' + (0, _collections$1.printIteratorEntries)(val.entries(), config, indentation, depth, refs, printer) + '}'; // Record has an entries method because it is a collection in immutable v3. // Return an iterator for Immutable Record from version v3 or v4. function getRecordEntries(val) { let i = 0; return { next() { if (i < val._keys.length) { const key = val._keys[i++]; return { done: false, value: [key, val.get(key)] }; } return { done: true, value: undefined }; } }; } const printImmutableRecord = (val, config, indentation, depth, refs, printer) => { // _name property is defined only for an Immutable Record instance // which was constructed with a second optional descriptive name arg const name = getImmutableName(val._name || 'Record'); return ++depth > config.maxDepth ? printAsLeaf(name) : name + SPACE + '{' + (0, _collections$1.printIteratorEntries)(getRecordEntries(val), config, indentation, depth, refs, printer) + '}'; }; const printImmutableSeq = (val, config, indentation, depth, refs, printer) => { const name = getImmutableName('Seq'); if (++depth > config.maxDepth) { return printAsLeaf(name); } if (val[IS_KEYED_SENTINEL]) { return name + SPACE + '{' + ( // from Immutable collection of entries or from ECMAScript object val._iter || val._object ? (0, _collections$1.printIteratorEntries)(val.entries(), config, indentation, depth, refs, printer) : LAZY) + '}'; } return name + SPACE + '[' + (val._iter || // from Immutable collection of values val._array || // from ECMAScript array val._collection || // from ECMAScript collection in immutable v4 val._iterable // from ECMAScript collection in immutable v3 ? (0, _collections$1.printIteratorValues)(val.values(), config, indentation, depth, refs, printer) : LAZY) + ']'; }; const printImmutableValues = (val, config, indentation, depth, refs, printer, type) => ++depth > config.maxDepth ? printAsLeaf(getImmutableName(type)) : getImmutableName(type) + SPACE + '[' + (0, _collections$1.printIteratorValues)(val.values(), config, indentation, depth, refs, printer) + ']'; const serialize$2 = (val, config, indentation, depth, refs, printer) => { if (val[IS_MAP_SENTINEL]) { return printImmutableEntries(val, config, indentation, depth, refs, printer, val[IS_ORDERED_SENTINEL] ? 'OrderedMap' : 'Map'); } if (val[IS_LIST_SENTINEL]) { return printImmutableValues(val, config, indentation, depth, refs, printer, 'List'); } if (val[IS_SET_SENTINEL]) { return printImmutableValues(val, config, indentation, depth, refs, printer, val[IS_ORDERED_SENTINEL] ? 'OrderedSet' : 'Set'); } if (val[IS_STACK_SENTINEL]) { return printImmutableValues(val, config, indentation, depth, refs, printer, 'Stack'); } if (val[IS_SEQ_SENTINEL]) { return printImmutableSeq(val, config, indentation, depth, refs, printer); } // For compatibility with immutable v3 and v4, let record be the default. return printImmutableRecord(val, config, indentation, depth, refs, printer); }; // Explicitly comparing sentinel properties to true avoids false positive // when mock identity-obj-proxy returns the key as the value for any key. Immutable.serialize = serialize$2; const test$2 = val => val && (val[IS_ITERABLE_SENTINEL] === true || val[IS_RECORD_SENTINEL] === true); Immutable.test = test$2; const plugin$2 = { serialize: serialize$2, test: test$2 }; var _default$2g = plugin$2; Immutable.default = _default$2g; var ReactElement = {}; var reactIs = {exports: {}}; /** @license React v17.0.2 * react-is.production.min.js * * Copyright (c) Facebook, Inc. and its affiliates. * * This source code is licensed under the MIT license found in the * LICENSE file in the root directory of this source tree. */ if ("function" === typeof Symbol && Symbol.for) { var x = Symbol.for; x("react.element"); x("react.portal"); x("react.fragment"); x("react.strict_mode"); x("react.profiler"); x("react.provider"); x("react.context"); x("react.forward_ref"); x("react.suspense"); x("react.suspense_list"); x("react.memo"); x("react.lazy"); x("react.block"); x("react.server.block"); x("react.fundamental"); x("react.debug_trace_mode"); x("react.legacy_hidden"); } var reactIs_development = {}; /** @license React v17.0.2 * react-is.development.js * * Copyright (c) Facebook, Inc. and its affiliates. * * This source code is licensed under the MIT license found in the * LICENSE file in the root directory of this source tree. */ { (function () { // When adding new symbols to this file, // Please consider also adding to 'react-devtools-shared/src/backend/ReactSymbols' // The Symbol used to tag the ReactElement-like types. If there is no native Symbol // nor polyfill, then a plain number is used for performance. var REACT_ELEMENT_TYPE = 0xeac7; var REACT_PORTAL_TYPE = 0xeaca; var REACT_FRAGMENT_TYPE = 0xeacb; var REACT_STRICT_MODE_TYPE = 0xeacc; var REACT_PROFILER_TYPE = 0xead2; var REACT_PROVIDER_TYPE = 0xeacd; var REACT_CONTEXT_TYPE = 0xeace; var REACT_FORWARD_REF_TYPE = 0xead0; var REACT_SUSPENSE_TYPE = 0xead1; var REACT_SUSPENSE_LIST_TYPE = 0xead8; var REACT_MEMO_TYPE = 0xead3; var REACT_LAZY_TYPE = 0xead4; var REACT_BLOCK_TYPE = 0xead9; var REACT_SERVER_BLOCK_TYPE = 0xeada; var REACT_FUNDAMENTAL_TYPE = 0xead5; var REACT_DEBUG_TRACING_MODE_TYPE = 0xeae1; var REACT_LEGACY_HIDDEN_TYPE = 0xeae3; if (typeof Symbol === 'function' && Symbol.for) { var symbolFor = Symbol.for; REACT_ELEMENT_TYPE = symbolFor('react.element'); REACT_PORTAL_TYPE = symbolFor('react.portal'); REACT_FRAGMENT_TYPE = symbolFor('react.fragment'); REACT_STRICT_MODE_TYPE = symbolFor('react.strict_mode'); REACT_PROFILER_TYPE = symbolFor('react.profiler'); REACT_PROVIDER_TYPE = symbolFor('react.provider'); REACT_CONTEXT_TYPE = symbolFor('react.context'); REACT_FORWARD_REF_TYPE = symbolFor('react.forward_ref'); REACT_SUSPENSE_TYPE = symbolFor('react.suspense'); REACT_SUSPENSE_LIST_TYPE = symbolFor('react.suspense_list'); REACT_MEMO_TYPE = symbolFor('react.memo'); REACT_LAZY_TYPE = symbolFor('react.lazy'); REACT_BLOCK_TYPE = symbolFor('react.block'); REACT_SERVER_BLOCK_TYPE = symbolFor('react.server.block'); REACT_FUNDAMENTAL_TYPE = symbolFor('react.fundamental'); symbolFor('react.scope'); symbolFor('react.opaque.id'); REACT_DEBUG_TRACING_MODE_TYPE = symbolFor('react.debug_trace_mode'); symbolFor('react.offscreen'); REACT_LEGACY_HIDDEN_TYPE = symbolFor('react.legacy_hidden'); } // Filter certain DOM attributes (e.g. src, href) if their values are empty strings. var enableScopeAPI = false; // Experimental Create Event Handle API. function isValidElementType(type) { if (typeof type === 'string' || typeof type === 'function') { return true; } // Note: typeof might be other than 'symbol' or 'number' (e.g. if it's a polyfill). if (type === REACT_FRAGMENT_TYPE || type === REACT_PROFILER_TYPE || type === REACT_DEBUG_TRACING_MODE_TYPE || type === REACT_STRICT_MODE_TYPE || type === REACT_SUSPENSE_TYPE || type === REACT_SUSPENSE_LIST_TYPE || type === REACT_LEGACY_HIDDEN_TYPE || enableScopeAPI) { return true; } if (typeof type === 'object' && type !== null) { if (type.$$typeof === REACT_LAZY_TYPE || type.$$typeof === REACT_MEMO_TYPE || type.$$typeof === REACT_PROVIDER_TYPE || type.$$typeof === REACT_CONTEXT_TYPE || type.$$typeof === REACT_FORWARD_REF_TYPE || type.$$typeof === REACT_FUNDAMENTAL_TYPE || type.$$typeof === REACT_BLOCK_TYPE || type[0] === REACT_SERVER_BLOCK_TYPE) { return true; } } return false; } function typeOf(object) { if (typeof object === 'object' && object !== null) { var $$typeof = object.$$typeof; switch ($$typeof) { case REACT_ELEMENT_TYPE: var type = object.type; switch (type) { case REACT_FRAGMENT_TYPE: case REACT_PROFILER_TYPE: case REACT_STRICT_MODE_TYPE: case REACT_SUSPENSE_TYPE: case REACT_SUSPENSE_LIST_TYPE: return type; default: var $$typeofType = type && type.$$typeof; switch ($$typeofType) { case REACT_CONTEXT_TYPE: case REACT_FORWARD_REF_TYPE: case REACT_LAZY_TYPE: case REACT_MEMO_TYPE: case REACT_PROVIDER_TYPE: return $$typeofType; default: return $$typeof; } } case REACT_PORTAL_TYPE: return $$typeof; } } return undefined; } var ContextConsumer = REACT_CONTEXT_TYPE; var ContextProvider = REACT_PROVIDER_TYPE; var Element = REACT_ELEMENT_TYPE; var ForwardRef = REACT_FORWARD_REF_TYPE; var Fragment = REACT_FRAGMENT_TYPE; var Lazy = REACT_LAZY_TYPE; var Memo = REACT_MEMO_TYPE; var Portal = REACT_PORTAL_TYPE; var Profiler = REACT_PROFILER_TYPE; var StrictMode = REACT_STRICT_MODE_TYPE; var Suspense = REACT_SUSPENSE_TYPE; var hasWarnedAboutDeprecatedIsAsyncMode = false; var hasWarnedAboutDeprecatedIsConcurrentMode = false; // AsyncMode should be deprecated function isAsyncMode(object) { { if (!hasWarnedAboutDeprecatedIsAsyncMode) { hasWarnedAboutDeprecatedIsAsyncMode = true; // Using console['warn'] to evade Babel and ESLint console['warn']('The ReactIs.isAsyncMode() alias has been deprecated, ' + 'and will be removed in React 18+.'); } } return false; } function isConcurrentMode(object) { { if (!hasWarnedAboutDeprecatedIsConcurrentMode) { hasWarnedAboutDeprecatedIsConcurrentMode = true; // Using console['warn'] to evade Babel and ESLint console['warn']('The ReactIs.isConcurrentMode() alias has been deprecated, ' + 'and will be removed in React 18+.'); } } return false; } function isContextConsumer(object) { return typeOf(object) === REACT_CONTEXT_TYPE; } function isContextProvider(object) { return typeOf(object) === REACT_PROVIDER_TYPE; } function isElement(object) { return typeof object === 'object' && object !== null && object.$$typeof === REACT_ELEMENT_TYPE; } function isForwardRef(object) { return typeOf(object) === REACT_FORWARD_REF_TYPE; } function isFragment(object) { return typeOf(object) === REACT_FRAGMENT_TYPE; } function isLazy(object) { return typeOf(object) === REACT_LAZY_TYPE; } function isMemo(object) { return typeOf(object) === REACT_MEMO_TYPE; } function isPortal(object) { return typeOf(object) === REACT_PORTAL_TYPE; } function isProfiler(object) { return typeOf(object) === REACT_PROFILER_TYPE; } function isStrictMode(object) { return typeOf(object) === REACT_STRICT_MODE_TYPE; } function isSuspense(object) { return typeOf(object) === REACT_SUSPENSE_TYPE; } reactIs_development.ContextConsumer = ContextConsumer; reactIs_development.ContextProvider = ContextProvider; reactIs_development.Element = Element; reactIs_development.ForwardRef = ForwardRef; reactIs_development.Fragment = Fragment; reactIs_development.Lazy = Lazy; reactIs_development.Memo = Memo; reactIs_development.Portal = Portal; reactIs_development.Profiler = Profiler; reactIs_development.StrictMode = StrictMode; reactIs_development.Suspense = Suspense; reactIs_development.isAsyncMode = isAsyncMode; reactIs_development.isConcurrentMode = isConcurrentMode; reactIs_development.isContextConsumer = isContextConsumer; reactIs_development.isContextProvider = isContextProvider; reactIs_development.isElement = isElement; reactIs_development.isForwardRef = isForwardRef; reactIs_development.isFragment = isFragment; reactIs_development.isLazy = isLazy; reactIs_development.isMemo = isMemo; reactIs_development.isPortal = isPortal; reactIs_development.isProfiler = isProfiler; reactIs_development.isStrictMode = isStrictMode; reactIs_development.isSuspense = isSuspense; reactIs_development.isValidElementType = isValidElementType; reactIs_development.typeOf = typeOf; })(); } { reactIs.exports = reactIs_development; } Object.defineProperty(ReactElement, '__esModule', { value: true }); ReactElement.test = ReactElement.serialize = ReactElement.default = void 0; var ReactIs = _interopRequireWildcard(reactIs.exports); var _markup$1 = markup; function _getRequireWildcardCache(nodeInterop) { if (typeof WeakMap !== 'function') return null; var cacheBabelInterop = new WeakMap(); var cacheNodeInterop = new WeakMap(); return (_getRequireWildcardCache = function (nodeInterop) { return nodeInterop ? cacheNodeInterop : cacheBabelInterop; })(nodeInterop); } function _interopRequireWildcard(obj, nodeInterop) { if (!nodeInterop && obj && obj.__esModule) { return obj; } if (obj === null || typeof obj !== 'object' && typeof obj !== 'function') { return { default: obj }; } var cache = _getRequireWildcardCache(nodeInterop); if (cache && cache.has(obj)) { return cache.get(obj); } var newObj = {}; var hasPropertyDescriptor = Object.defineProperty && Object.getOwnPropertyDescriptor; for (var key in obj) { if (key !== 'default' && Object.prototype.hasOwnProperty.call(obj, key)) { var desc = hasPropertyDescriptor ? Object.getOwnPropertyDescriptor(obj, key) : null; if (desc && (desc.get || desc.set)) { Object.defineProperty(newObj, key, desc); } else { newObj[key] = obj[key]; } } } newObj.default = obj; if (cache) { cache.set(obj, newObj); } return newObj; } /** * Copyright (c) Facebook, Inc. and its affiliates. All Rights Reserved. * * This source code is licensed under the MIT license found in the * LICENSE file in the root directory of this source tree. */ // Given element.props.children, or subtree during recursive traversal, // return flattened array of children. const getChildren = function (arg, children) { if (children === void 0) { children = []; } if (Array.isArray(arg)) { arg.forEach(item => { getChildren(item, children); }); } else if (arg != null && arg !== false) { children.push(arg); } return children; }; const getType = element => { const type = element.type; if (typeof type === 'string') { return type; } if (typeof type === 'function') { return type.displayName || type.name || 'Unknown'; } if (ReactIs.isFragment(element)) { return 'React.Fragment'; } if (ReactIs.isSuspense(element)) { return 'React.Suspense'; } if (typeof type === 'object' && type !== null) { if (ReactIs.isContextProvider(element)) { return 'Context.Provider'; } if (ReactIs.isContextConsumer(element)) { return 'Context.Consumer'; } if (ReactIs.isForwardRef(element)) { if (type.displayName) { return type.displayName; } const functionName = type.render.displayName || type.render.name || ''; return functionName !== '' ? 'ForwardRef(' + functionName + ')' : 'ForwardRef'; } if (ReactIs.isMemo(element)) { const functionName = type.displayName || type.type.displayName || type.type.name || ''; return functionName !== '' ? 'Memo(' + functionName + ')' : 'Memo'; } } return 'UNDEFINED'; }; const getPropKeys$1 = element => { const { props } = element; return Object.keys(props).filter(key => key !== 'children' && props[key] !== undefined).sort(); }; const serialize$1 = (element, config, indentation, depth, refs, printer) => ++depth > config.maxDepth ? (0, _markup$1.printElementAsLeaf)(getType(element), config) : (0, _markup$1.printElement)(getType(element), (0, _markup$1.printProps)(getPropKeys$1(element), element.props, config, indentation + config.indent, depth, refs, printer), (0, _markup$1.printChildren)(getChildren(element.props.children), config, indentation + config.indent, depth, refs, printer), config, indentation); ReactElement.serialize = serialize$1; const test$1 = val => val != null && ReactIs.isElement(val); ReactElement.test = test$1; const plugin$1 = { serialize: serialize$1, test: test$1 }; var _default$2f = plugin$1; ReactElement.default = _default$2f; var ReactTestComponent = {}; Object.defineProperty(ReactTestComponent, '__esModule', { value: true }); ReactTestComponent.test = ReactTestComponent.serialize = ReactTestComponent.default = void 0; var _markup = markup; var global = function () { if (typeof globalThis !== 'undefined') { return globalThis; } else if (typeof global !== 'undefined') { return global; } else if (typeof self !== 'undefined') { return self; } else if (typeof window !== 'undefined') { return window; } else { return Function('return this')(); } }(); var Symbol$1 = global['jest-symbol-do-not-touch'] || global.Symbol; const testSymbol = typeof Symbol$1 === 'function' && Symbol$1.for ? Symbol$1.for('react.test.json') : 0xea71357; const getPropKeys = object => { const { props } = object; return props ? Object.keys(props).filter(key => props[key] !== undefined).sort() : []; }; const serialize = (object, config, indentation, depth, refs, printer) => ++depth > config.maxDepth ? (0, _markup.printElementAsLeaf)(object.type, config) : (0, _markup.printElement)(object.type, object.props ? (0, _markup.printProps)(getPropKeys(object), object.props, config, indentation + config.indent, depth, refs, printer) : '', object.children ? (0, _markup.printChildren)(object.children, config, indentation + config.indent, depth, refs, printer) : '', config, indentation); ReactTestComponent.serialize = serialize; const test = val => val && val.$$typeof === testSymbol; ReactTestComponent.test = test; const plugin = { serialize, test }; var _default$2e = plugin; ReactTestComponent.default = _default$2e; Object.defineProperty(build, '__esModule', { value: true }); var default_1 = build.default = DEFAULT_OPTIONS_1 = build.DEFAULT_OPTIONS = void 0; var format_1 = build.format = format; var plugins_1 = build.plugins = void 0; var _ansiStyles = _interopRequireDefault$7(ansiStyles.exports); var _collections = collections; var _AsymmetricMatcher = _interopRequireDefault$7(AsymmetricMatcher); var _ConvertAnsi = _interopRequireDefault$7(ConvertAnsi); var _DOMCollection = _interopRequireDefault$7(DOMCollection$1); var _DOMElement = _interopRequireDefault$7(DOMElement); var _Immutable = _interopRequireDefault$7(Immutable); var _ReactElement = _interopRequireDefault$7(ReactElement); var _ReactTestComponent = _interopRequireDefault$7(ReactTestComponent); function _interopRequireDefault$7(obj) { return obj && obj.__esModule ? obj : { default: obj }; } /** * Copyright (c) Facebook, Inc. and its affiliates. All Rights Reserved. * * This source code is licensed under the MIT license found in the * LICENSE file in the root directory of this source tree. */ /* eslint-disable local/ban-types-eventually */ const toString = Object.prototype.toString; const toISOString = Date.prototype.toISOString; const errorToString = Error.prototype.toString; const regExpToString = RegExp.prototype.toString; /** * Explicitly comparing typeof constructor to function avoids undefined as name * when mock identity-obj-proxy returns the key as the value for any key. */ const getConstructorName = val => typeof val.constructor === 'function' && val.constructor.name || 'Object'; /* global window */ /** Is val is equal to global window object? Works even if it does not exist :) */ const isWindow = val => typeof window !== 'undefined' && val === window; const SYMBOL_REGEXP = /^Symbol\((.*)\)(.*)$/; const NEWLINE_REGEXP = /\n/gi; class PrettyFormatPluginError extends Error { constructor(message, stack) { super(message); this.stack = stack; this.name = this.constructor.name; } } function isToStringedArrayType(toStringed) { return toStringed === '[object Array]' || toStringed === '[object ArrayBuffer]' || toStringed === '[object DataView]' || toStringed === '[object Float32Array]' || toStringed === '[object Float64Array]' || toStringed === '[object Int8Array]' || toStringed === '[object Int16Array]' || toStringed === '[object Int32Array]' || toStringed === '[object Uint8Array]' || toStringed === '[object Uint8ClampedArray]' || toStringed === '[object Uint16Array]' || toStringed === '[object Uint32Array]'; } function printNumber(val) { return Object.is(val, -0) ? '-0' : String(val); } function printBigInt(val) { return String(val + "n"); } function printFunction(val, printFunctionName) { if (!printFunctionName) { return '[Function]'; } return '[Function ' + (val.name || 'anonymous') + ']'; } function printSymbol(val) { return String(val).replace(SYMBOL_REGEXP, 'Symbol($1)'); } function printError(val) { return '[' + errorToString.call(val) + ']'; } /** * The first port of call for printing an object, handles most of the * data-types in JS. */ function printBasicValue(val, printFunctionName, escapeRegex, escapeString) { if (val === true || val === false) { return '' + val; } if (val === undefined) { return 'undefined'; } if (val === null) { return 'null'; } const typeOf = typeof val; if (typeOf === 'number') { return printNumber(val); } if (typeOf === 'bigint') { return printBigInt(val); } if (typeOf === 'string') { if (escapeString) { return '"' + val.replace(/"|\\/g, '\\$&') + '"'; } return '"' + val + '"'; } if (typeOf === 'function') { return printFunction(val, printFunctionName); } if (typeOf === 'symbol') { return printSymbol(val); } const toStringed = toString.call(val); if (toStringed === '[object WeakMap]') { return 'WeakMap {}'; } if (toStringed === '[object WeakSet]') { return 'WeakSet {}'; } if (toStringed === '[object Function]' || toStringed === '[object GeneratorFunction]') { return printFunction(val, printFunctionName); } if (toStringed === '[object Symbol]') { return printSymbol(val); } if (toStringed === '[object Date]') { return isNaN(+val) ? 'Date { NaN }' : toISOString.call(val); } if (toStringed === '[object Error]') { return printError(val); } if (toStringed === '[object RegExp]') { if (escapeRegex) { // https://github.com/benjamingr/RegExp.escape/blob/main/polyfill.js return regExpToString.call(val).replace(/[\\^$*+?.()|[\]{}]/g, '\\$&'); } return regExpToString.call(val); } if (val instanceof Error) { return printError(val); } return null; } /** * Handles more complex objects ( such as objects with circular references. * maps and sets etc ) */ function printComplexValue(val, config, indentation, depth, refs, hasCalledToJSON) { if (refs.indexOf(val) !== -1) { return '[Circular]'; } refs = refs.slice(); refs.push(val); const hitMaxDepth = ++depth > config.maxDepth; const min = config.min; if (config.callToJSON && !hitMaxDepth && val.toJSON && typeof val.toJSON === 'function' && !hasCalledToJSON) { return printer(val.toJSON(), config, indentation, depth, refs, true); } const toStringed = toString.call(val); if (toStringed === '[object Arguments]') { return hitMaxDepth ? '[Arguments]' : (min ? '' : 'Arguments ') + '[' + (0, _collections.printListItems)(val, config, indentation, depth, refs, printer) + ']'; } if (isToStringedArrayType(toStringed)) { return hitMaxDepth ? '[' + val.constructor.name + ']' : (min ? '' : !config.printBasicPrototype && val.constructor.name === 'Array' ? '' : val.constructor.name + ' ') + '[' + (0, _collections.printListItems)(val, config, indentation, depth, refs, printer) + ']'; } if (toStringed === '[object Map]') { return hitMaxDepth ? '[Map]' : 'Map {' + (0, _collections.printIteratorEntries)(val.entries(), config, indentation, depth, refs, printer, ' => ') + '}'; } if (toStringed === '[object Set]') { return hitMaxDepth ? '[Set]' : 'Set {' + (0, _collections.printIteratorValues)(val.values(), config, indentation, depth, refs, printer) + '}'; } // Avoid failure to serialize global window object in jsdom test environment. // For example, not even relevant if window is prop of React element. return hitMaxDepth || isWindow(val) ? '[' + getConstructorName(val) + ']' : (min ? '' : !config.printBasicPrototype && getConstructorName(val) === 'Object' ? '' : getConstructorName(val) + ' ') + '{' + (0, _collections.printObjectProperties)(val, config, indentation, depth, refs, printer) + '}'; } function isNewPlugin(plugin) { return plugin.serialize != null; } function printPlugin(plugin, val, config, indentation, depth, refs) { let printed; try { printed = isNewPlugin(plugin) ? plugin.serialize(val, config, indentation, depth, refs, printer) : plugin.print(val, valChild => printer(valChild, config, indentation, depth, refs), str => { const indentationNext = indentation + config.indent; return indentationNext + str.replace(NEWLINE_REGEXP, '\n' + indentationNext); }, { edgeSpacing: config.spacingOuter, min: config.min, spacing: config.spacingInner }, config.colors); } catch (error) { throw new PrettyFormatPluginError(error.message, error.stack); } if (typeof printed !== 'string') { throw new Error("pretty-format: Plugin must return type \"string\" but instead returned \"" + typeof printed + "\"."); } return printed; } function findPlugin(plugins, val) { for (let p = 0; p < plugins.length; p++) { try { if (plugins[p].test(val)) { return plugins[p]; } } catch (error) { throw new PrettyFormatPluginError(error.message, error.stack); } } return null; } function printer(val, config, indentation, depth, refs, hasCalledToJSON) { const plugin = findPlugin(config.plugins, val); if (plugin !== null) { return printPlugin(plugin, val, config, indentation, depth, refs); } const basicResult = printBasicValue(val, config.printFunctionName, config.escapeRegex, config.escapeString); if (basicResult !== null) { return basicResult; } return printComplexValue(val, config, indentation, depth, refs, hasCalledToJSON); } const DEFAULT_THEME = { comment: 'gray', content: 'reset', prop: 'yellow', tag: 'cyan', value: 'green' }; const DEFAULT_THEME_KEYS = Object.keys(DEFAULT_THEME); const DEFAULT_OPTIONS = { callToJSON: true, compareKeys: undefined, escapeRegex: false, escapeString: true, highlight: false, indent: 2, maxDepth: Infinity, min: false, plugins: [], printBasicPrototype: true, printFunctionName: true, theme: DEFAULT_THEME }; var DEFAULT_OPTIONS_1 = build.DEFAULT_OPTIONS = DEFAULT_OPTIONS; function validateOptions(options) { Object.keys(options).forEach(key => { if (!DEFAULT_OPTIONS.hasOwnProperty(key)) { throw new Error("pretty-format: Unknown option \"" + key + "\"."); } }); if (options.min && options.indent !== undefined && options.indent !== 0) { throw new Error('pretty-format: Options "min" and "indent" cannot be used together.'); } if (options.theme !== undefined) { if (options.theme === null) { throw new Error('pretty-format: Option "theme" must not be null.'); } if (typeof options.theme !== 'object') { throw new Error("pretty-format: Option \"theme\" must be of type \"object\" but instead received \"" + typeof options.theme + "\"."); } } } const getColorsHighlight = options => DEFAULT_THEME_KEYS.reduce((colors, key) => { const value = options.theme && options.theme[key] !== undefined ? options.theme[key] : DEFAULT_THEME[key]; const color = value && _ansiStyles.default[value]; if (color && typeof color.close === 'string' && typeof color.open === 'string') { colors[key] = color; } else { throw new Error("pretty-format: Option \"theme\" has a key \"" + key + "\" whose value \"" + value + "\" is undefined in ansi-styles."); } return colors; }, Object.create(null)); const getColorsEmpty = () => DEFAULT_THEME_KEYS.reduce((colors, key) => { colors[key] = { close: '', open: '' }; return colors; }, Object.create(null)); const getPrintFunctionName = options => options && options.printFunctionName !== undefined ? options.printFunctionName : DEFAULT_OPTIONS.printFunctionName; const getEscapeRegex = options => options && options.escapeRegex !== undefined ? options.escapeRegex : DEFAULT_OPTIONS.escapeRegex; const getEscapeString = options => options && options.escapeString !== undefined ? options.escapeString : DEFAULT_OPTIONS.escapeString; const getConfig$1 = options => { var _options$printBasicPr; return { callToJSON: options && options.callToJSON !== undefined ? options.callToJSON : DEFAULT_OPTIONS.callToJSON, colors: options && options.highlight ? getColorsHighlight(options) : getColorsEmpty(), compareKeys: options && typeof options.compareKeys === 'function' ? options.compareKeys : DEFAULT_OPTIONS.compareKeys, escapeRegex: getEscapeRegex(options), escapeString: getEscapeString(options), indent: options && options.min ? '' : createIndent(options && options.indent !== undefined ? options.indent : DEFAULT_OPTIONS.indent), maxDepth: options && options.maxDepth !== undefined ? options.maxDepth : DEFAULT_OPTIONS.maxDepth, min: options && options.min !== undefined ? options.min : DEFAULT_OPTIONS.min, plugins: options && options.plugins !== undefined ? options.plugins : DEFAULT_OPTIONS.plugins, printBasicPrototype: (_options$printBasicPr = options === null || options === void 0 ? void 0 : options.printBasicPrototype) !== null && _options$printBasicPr !== void 0 ? _options$printBasicPr : true, printFunctionName: getPrintFunctionName(options), spacingInner: options && options.min ? ' ' : '\n', spacingOuter: options && options.min ? '' : '\n' }; }; function createIndent(indent) { return new Array(indent + 1).join(' '); } /** * Returns a presentation string of your `val` object * @param val any potential JavaScript object * @param options Custom settings */ function format(val, options) { if (options) { validateOptions(options); if (options.plugins) { const plugin = findPlugin(options.plugins, val); if (plugin !== null) { return printPlugin(plugin, val, getConfig$1(options), '', 0, []); } } } const basicResult = printBasicValue(val, getPrintFunctionName(options), getEscapeRegex(options), getEscapeString(options)); if (basicResult !== null) { return basicResult; } return printComplexValue(val, getConfig$1(options), '', 0, []); } const plugins = { AsymmetricMatcher: _AsymmetricMatcher.default, ConvertAnsi: _ConvertAnsi.default, DOMCollection: _DOMCollection.default, DOMElement: _DOMElement.default, Immutable: _Immutable.default, ReactElement: _ReactElement.default, ReactTestComponent: _ReactTestComponent.default }; plugins_1 = build.plugins = plugins; var _default$2d = format; default_1 = build.default = _default$2d; var index = /*#__PURE__*/_mergeNamespaces({ __proto__: null, get DEFAULT_OPTIONS () { return DEFAULT_OPTIONS_1; }, format: format_1, get plugins () { return plugins_1; }, get default () { return default_1; } }, [build]); /** * Source: https://github.com/facebook/jest/blob/e7bb6a1e26ffab90611b2593912df15b69315611/packages/pretty-format/src/plugins/DOMElement.ts */ /* eslint-disable -- trying to stay as close to the original as possible */ /* istanbul ignore file */ function escapeHTML(str) { return str.replace(//g, '>'); } // Return empty string if keys is empty. const printProps = (keys, props, config, indentation, depth, refs, printer) => { const indentationNext = indentation + config.indent; const colors = config.colors; return keys.map(key => { const value = props[key]; let printed = printer(value, config, indentationNext, depth, refs); if (typeof value !== 'string') { if (printed.indexOf('\n') !== -1) { printed = config.spacingOuter + indentationNext + printed + config.spacingOuter + indentation; } printed = '{' + printed + '}'; } return config.spacingInner + indentation + colors.prop.open + key + colors.prop.close + '=' + colors.value.open + printed + colors.value.close; }).join(''); }; // https://developer.mozilla.org/en-US/docs/Web/API/Node/nodeType#node_type_constants const NodeTypeTextNode = 3; // Return empty string if children is empty. const printChildren = (children, config, indentation, depth, refs, printer) => children.map(child => { const printedChild = typeof child === 'string' ? printText(child, config) : printer(child, config, indentation, depth, refs); if (printedChild === '' && typeof child === 'object' && child !== null && child.nodeType !== NodeTypeTextNode) { // A plugin serialized this Node to '' meaning we should ignore it. return ''; } return config.spacingOuter + indentation + printedChild; }).join(''); const printText = (text, config) => { const contentColor = config.colors.content; return contentColor.open + escapeHTML(text) + contentColor.close; }; const printComment = (comment, config) => { const commentColor = config.colors.comment; return commentColor.open + '' + commentColor.close; }; // Separate the functions to format props, children, and element, // so a plugin could override a particular function, if needed. // Too bad, so sad: the traditional (but unnecessary) space // in a self-closing tagColor requires a second test of printedProps. const printElement = (type, printedProps, printedChildren, config, indentation) => { const tagColor = config.colors.tag; return tagColor.open + '<' + type + (printedProps && tagColor.close + printedProps + config.spacingOuter + indentation + tagColor.open) + (printedChildren ? '>' + tagColor.close + printedChildren + config.spacingOuter + indentation + tagColor.open + '' + tagColor.close; }; const printElementAsLeaf = (type, config) => { const tagColor = config.colors.tag; return tagColor.open + '<' + type + tagColor.close + ' …' + tagColor.open + ' />' + tagColor.close; }; const ELEMENT_NODE$1 = 1; const TEXT_NODE$1 = 3; const COMMENT_NODE$1 = 8; const FRAGMENT_NODE = 11; const ELEMENT_REGEXP = /^((HTML|SVG)\w*)?Element$/; const testNode = val => { const constructorName = val.constructor.name; const { nodeType, tagName } = val; const isCustomElement = typeof tagName === 'string' && tagName.includes('-') || typeof val.hasAttribute === 'function' && val.hasAttribute('is'); return nodeType === ELEMENT_NODE$1 && (ELEMENT_REGEXP.test(constructorName) || isCustomElement) || nodeType === TEXT_NODE$1 && constructorName === 'Text' || nodeType === COMMENT_NODE$1 && constructorName === 'Comment' || nodeType === FRAGMENT_NODE && constructorName === 'DocumentFragment'; }; function nodeIsText(node) { return node.nodeType === TEXT_NODE$1; } function nodeIsComment(node) { return node.nodeType === COMMENT_NODE$1; } function nodeIsFragment(node) { return node.nodeType === FRAGMENT_NODE; } function createDOMElementFilter(filterNode) { return { test: val => { var _val$constructor2; return (val == null ? void 0 : (_val$constructor2 = val.constructor) == null ? void 0 : _val$constructor2.name) && testNode(val); }, serialize: (node, config, indentation, depth, refs, printer) => { if (nodeIsText(node)) { return printText(node.data, config); } if (nodeIsComment(node)) { return printComment(node.data, config); } const type = nodeIsFragment(node) ? "DocumentFragment" : node.tagName.toLowerCase(); if (++depth > config.maxDepth) { return printElementAsLeaf(type, config); } return printElement(type, printProps(nodeIsFragment(node) ? [] : Array.from(node.attributes).map(attr => attr.name).sort(), nodeIsFragment(node) ? {} : Array.from(node.attributes).reduce((props, attribute) => { props[attribute.name] = attribute.value; return props; }, {}), config, indentation + config.indent, depth, refs, printer), printChildren(Array.prototype.slice.call(node.childNodes || node.children).filter(filterNode), config, indentation + config.indent, depth, refs, printer), config, indentation); } }; } // We try to load node dependencies let chalk = null; let readFileSync = null; let codeFrameColumns = null; try { const nodeRequire = module && module.require; readFileSync = nodeRequire.call(module, 'fs').readFileSync; codeFrameColumns = nodeRequire.call(module, '@babel/code-frame').codeFrameColumns; chalk = nodeRequire.call(module, 'chalk'); } catch {// We're in a browser environment } // frame has the form "at myMethod (location/to/my/file.js:10:2)" function getCodeFrame(frame) { const locationStart = frame.indexOf('(') + 1; const locationEnd = frame.indexOf(')'); const frameLocation = frame.slice(locationStart, locationEnd); const frameLocationElements = frameLocation.split(':'); const [filename, line, column] = [frameLocationElements[0], parseInt(frameLocationElements[1], 10), parseInt(frameLocationElements[2], 10)]; let rawFileContents = ''; try { rawFileContents = readFileSync(filename, 'utf-8'); } catch { return ''; } const codeFrame = codeFrameColumns(rawFileContents, { start: { line, column } }, { highlightCode: true, linesBelow: 0 }); return chalk.dim(frameLocation) + "\n" + codeFrame + "\n"; } function getUserCodeFrame() { // If we couldn't load dependencies, we can't generate the user trace /* istanbul ignore next */ if (!readFileSync || !codeFrameColumns) { return ''; } const err = new Error(); const firstClientCodeFrame = err.stack.split('\n').slice(1) // Remove first line which has the form "Error: TypeError" .find(frame => !frame.includes('node_modules/')); // Ignore frames from 3rd party libraries return getCodeFrame(firstClientCodeFrame); } // Constant node.nodeType for text nodes, see: // https://developer.mozilla.org/en-US/docs/Web/API/Node/nodeType#Node_type_constants const TEXT_NODE = 3; function jestFakeTimersAreEnabled() { /* istanbul ignore else */ if (typeof jest !== 'undefined' && jest !== null) { return (// legacy timers setTimeout._isMockFunction === true || // modern timers Object.prototype.hasOwnProperty.call(setTimeout, 'clock') ); } // istanbul ignore next return false; } function getDocument() { /* istanbul ignore if */ if (typeof window === 'undefined') { throw new Error('Could not find default container'); } return window.document; } function getWindowFromNode(node) { if (node.defaultView) { // node is document return node.defaultView; } else if (node.ownerDocument && node.ownerDocument.defaultView) { // node is a DOM node return node.ownerDocument.defaultView; } else if (node.window) { // node is window return node.window; } else if (node.ownerDocument && node.ownerDocument.defaultView === null) { throw new Error("It looks like the window object is not available for the provided node."); } else if (node.then instanceof Function) { throw new Error("It looks like you passed a Promise object instead of a DOM node. Did you do something like `fireEvent.click(screen.findBy...` when you meant to use a `getBy` query `fireEvent.click(screen.getBy...`, or await the findBy query `fireEvent.click(await screen.findBy...`?"); } else if (Array.isArray(node)) { throw new Error("It looks like you passed an Array instead of a DOM node. Did you do something like `fireEvent.click(screen.getAllBy...` when you meant to use a `getBy` query `fireEvent.click(screen.getBy...`?"); } else if (typeof node.debug === 'function' && typeof node.logTestingPlaygroundURL === 'function') { throw new Error("It looks like you passed a `screen` object. Did you do something like `fireEvent.click(screen, ...` when you meant to use a query, e.g. `fireEvent.click(screen.getBy..., `?"); } else { // The user passed something unusual to a calling function throw new Error("The given node is not an Element, the node type is: " + typeof node + "."); } } function checkContainerType(container) { if (!container || !(typeof container.querySelector === 'function') || !(typeof container.querySelectorAll === 'function')) { throw new TypeError("Expected container to be an Element, a Document or a DocumentFragment but got " + getTypeName(container) + "."); } function getTypeName(object) { if (typeof object === 'object') { return object === null ? 'null' : object.constructor.name; } return typeof object; } } const inNode = () => typeof process !== 'undefined' && process.versions !== undefined && process.versions.node !== undefined; const { DOMCollection } = plugins_1; // https://developer.mozilla.org/en-US/docs/Web/API/Node/nodeType#node_type_constants const ELEMENT_NODE = 1; const COMMENT_NODE = 8; // https://github.com/facebook/jest/blob/615084195ae1ae61ddd56162c62bbdda17587569/packages/pretty-format/src/plugins/DOMElement.ts#L50 function filterCommentsAndDefaultIgnoreTagsTags(value) { return value.nodeType !== COMMENT_NODE && (value.nodeType !== ELEMENT_NODE || !value.matches(getConfig().defaultIgnore)); } function prettyDOM(dom, maxLength, options) { if (options === void 0) { options = {}; } if (!dom) { dom = getDocument().body; } if (typeof maxLength !== 'number') { maxLength = typeof process !== 'undefined' && undefined || 7000; } if (maxLength === 0) { return ''; } if (dom.documentElement) { dom = dom.documentElement; } let domTypeName = typeof dom; if (domTypeName === 'object') { domTypeName = dom.constructor.name; } else { // To don't fall with `in` operator dom = {}; } if (!('outerHTML' in dom)) { throw new TypeError("Expected an element or document but got " + domTypeName); } const { filterNode = filterCommentsAndDefaultIgnoreTagsTags, ...prettyFormatOptions } = options; const debugContent = format_1(dom, { plugins: [createDOMElementFilter(filterNode), DOMCollection], printFunctionName: false, highlight: inNode(), ...prettyFormatOptions }); return maxLength !== undefined && dom.outerHTML.length > maxLength ? debugContent.slice(0, maxLength) + "..." : debugContent; } const logDOM = function () { const userCodeFrame = getUserCodeFrame(); if (userCodeFrame) { console.log(prettyDOM(...arguments) + "\n\n" + userCodeFrame); } else { console.log(prettyDOM(...arguments)); } }; // It would be cleaner for this to live inside './queries', but // other parts of the code assume that all exports from // './queries' are query functions. let config = { testIdAttribute: 'data-testid', asyncUtilTimeout: 1000, // asyncWrapper and advanceTimersWrapper is to support React's async `act` function. // forcing react-testing-library to wrap all async functions would've been // a total nightmare (consider wrapping every findBy* query and then also // updating `within` so those would be wrapped too. Total nightmare). // so we have this config option that's really only intended for // react-testing-library to use. For that reason, this feature will remain // undocumented. asyncWrapper: cb => cb(), unstable_advanceTimersWrapper: cb => cb(), eventWrapper: cb => cb(), // default value for the `hidden` option in `ByRole` queries defaultHidden: false, // default value for the `ignore` option in `ByText` queries defaultIgnore: 'script, style', // showOriginalStackTrace flag to show the full error stack traces for async errors showOriginalStackTrace: false, // throw errors w/ suggestions for better queries. Opt in so off by default. throwSuggestions: false, // called when getBy* queries fail. (message, container) => Error getElementError(message, container) { const prettifiedDOM = prettyDOM(container); const error = new Error([message, "Ignored nodes: comments, " + config.defaultIgnore + "\n" + prettifiedDOM].filter(Boolean).join('\n\n')); error.name = 'TestingLibraryElementError'; return error; }, _disableExpensiveErrorDiagnostics: false, computedStyleSupportsPseudoElements: false }; function runWithExpensiveErrorDiagnosticsDisabled(callback) { try { config._disableExpensiveErrorDiagnostics = true; return callback(); } finally { config._disableExpensiveErrorDiagnostics = false; } } function configure(newConfig) { if (typeof newConfig === 'function') { // Pass the existing config out to the provided function // and accept a delta in return newConfig = newConfig(config); } // Merge the incoming config delta config = { ...config, ...newConfig }; } function getConfig() { return config; } const labelledNodeNames = ['button', 'meter', 'output', 'progress', 'select', 'textarea', 'input']; function getTextContent(node) { if (labelledNodeNames.includes(node.nodeName.toLowerCase())) { return ''; } if (node.nodeType === TEXT_NODE) return node.textContent; return Array.from(node.childNodes).map(childNode => getTextContent(childNode)).join(''); } function getLabelContent(element) { let textContent; if (element.tagName.toLowerCase() === 'label') { textContent = getTextContent(element); } else { textContent = element.value || element.textContent; } return textContent; } // Based on https://github.com/eps1lon/dom-accessibility-api/pull/352 function getRealLabels(element) { // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- types are not aware of older browsers that don't implement `labels` if (element.labels !== undefined) { var _labels; return (_labels = element.labels) != null ? _labels : []; } if (!isLabelable(element)) return []; const labels = element.ownerDocument.querySelectorAll('label'); return Array.from(labels).filter(label => label.control === element); } function isLabelable(element) { return /BUTTON|METER|OUTPUT|PROGRESS|SELECT|TEXTAREA/.test(element.tagName) || element.tagName === 'INPUT' && element.getAttribute('type') !== 'hidden'; } function getLabels$1(container, element, _temp) { let { selector = '*' } = _temp === void 0 ? {} : _temp; const ariaLabelledBy = element.getAttribute('aria-labelledby'); const labelsId = ariaLabelledBy ? ariaLabelledBy.split(' ') : []; return labelsId.length ? labelsId.map(labelId => { const labellingElement = container.querySelector("[id=\"" + labelId + "\"]"); return labellingElement ? { content: getLabelContent(labellingElement), formControl: null } : { content: '', formControl: null }; }) : Array.from(getRealLabels(element)).map(label => { const textToMatch = getLabelContent(label); const formControlSelector = 'button, input, meter, output, progress, select, textarea'; const labelledFormControl = Array.from(label.querySelectorAll(formControlSelector)).filter(formControlElement => formControlElement.matches(selector))[0]; return { content: textToMatch, formControl: labelledFormControl }; }); } function assertNotNullOrUndefined(matcher) { if (matcher === null || matcher === undefined) { throw new Error( // eslint-disable-next-line @typescript-eslint/restrict-template-expressions -- implicitly converting `T` to `string` "It looks like " + matcher + " was passed instead of a matcher. Did you do something like getByText(" + matcher + ")?"); } } function fuzzyMatches(textToMatch, node, matcher, normalizer) { if (typeof textToMatch !== 'string') { return false; } assertNotNullOrUndefined(matcher); const normalizedText = normalizer(textToMatch); if (typeof matcher === 'string' || typeof matcher === 'number') { return normalizedText.toLowerCase().includes(matcher.toString().toLowerCase()); } else if (typeof matcher === 'function') { return matcher(normalizedText, node); } else { return matchRegExp(matcher, normalizedText); } } function matches(textToMatch, node, matcher, normalizer) { if (typeof textToMatch !== 'string') { return false; } assertNotNullOrUndefined(matcher); const normalizedText = normalizer(textToMatch); if (matcher instanceof Function) { return matcher(normalizedText, node); } else if (matcher instanceof RegExp) { return matchRegExp(matcher, normalizedText); } else { return normalizedText === String(matcher); } } function getDefaultNormalizer(_temp) { let { trim = true, collapseWhitespace = true } = _temp === void 0 ? {} : _temp; return text => { let normalizedText = text; normalizedText = trim ? normalizedText.trim() : normalizedText; normalizedText = collapseWhitespace ? normalizedText.replace(/\s+/g, ' ') : normalizedText; return normalizedText; }; } /** * Constructs a normalizer to pass to functions in matches.js * @param {boolean|undefined} trim The user-specified value for `trim`, without * any defaulting having been applied * @param {boolean|undefined} collapseWhitespace The user-specified value for * `collapseWhitespace`, without any defaulting having been applied * @param {Function|undefined} normalizer The user-specified normalizer * @returns {Function} A normalizer */ function makeNormalizer(_ref) { let { trim, collapseWhitespace, normalizer } = _ref; if (!normalizer) { // No custom normalizer specified. Just use default. return getDefaultNormalizer({ trim, collapseWhitespace }); } if (typeof trim !== 'undefined' || typeof collapseWhitespace !== 'undefined') { // They've also specified a value for trim or collapseWhitespace throw new Error('trim and collapseWhitespace are not supported with a normalizer. ' + 'If you want to use the default trim and collapseWhitespace logic in your normalizer, ' + 'use "getDefaultNormalizer({trim, collapseWhitespace})" and compose that into your normalizer'); } return normalizer; } function matchRegExp(matcher, text) { const match = matcher.test(text); if (matcher.global && matcher.lastIndex !== 0) { console.warn("To match all elements we had to reset the lastIndex of the RegExp because the global flag is enabled. We encourage to remove the global flag from the RegExp."); matcher.lastIndex = 0; } return match; } function getNodeText(node) { if (node.matches('input[type=submit], input[type=button], input[type=reset]')) { return node.value; } return Array.from(node.childNodes).filter(child => child.nodeType === TEXT_NODE && Boolean(child.textContent)).map(c => c.textContent).join(''); } /** * @source {https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/from#Polyfill} * but without thisArg (too hard to type, no need to `this`) */ var toStr = Object.prototype.toString; function isCallable(fn) { return typeof fn === "function" || toStr.call(fn) === "[object Function]"; } function toInteger(value) { var number = Number(value); if (isNaN(number)) { return 0; } if (number === 0 || !isFinite(number)) { return number; } return (number > 0 ? 1 : -1) * Math.floor(Math.abs(number)); } var maxSafeInteger = Math.pow(2, 53) - 1; function toLength(value) { var len = toInteger(value); return Math.min(Math.max(len, 0), maxSafeInteger); } /** * Creates an array from an iterable object. * @param iterable An iterable object to convert to an array. */ /** * Creates an array from an iterable object. * @param iterable An iterable object to convert to an array. * @param mapfn A mapping function to call on every element of the array. * @param thisArg Value of 'this' used to invoke the mapfn. */ function arrayFrom(arrayLike, mapFn) { // 1. Let C be the this value. // edit(@eps1lon): we're not calling it as Array.from var C = Array; // 2. Let items be ToObject(arrayLike). var items = Object(arrayLike); // 3. ReturnIfAbrupt(items). if (arrayLike == null) { throw new TypeError("Array.from requires an array-like object - not null or undefined"); } // 4. If mapfn is undefined, then let mapping be false. // const mapFn = arguments.length > 1 ? arguments[1] : void undefined; if (typeof mapFn !== "undefined") { // 5. else // 5. a If IsCallable(mapfn) is false, throw a TypeError exception. if (!isCallable(mapFn)) { throw new TypeError("Array.from: when provided, the second argument must be a function"); } } // 10. Let lenValue be Get(items, "length"). // 11. Let len be ToLength(lenValue). var len = toLength(items.length); // 13. If IsConstructor(C) is true, then // 13. a. Let A be the result of calling the [[Construct]] internal method // of C with an argument list containing the single item len. // 14. a. Else, Let A be ArrayCreate(len). var A = isCallable(C) ? Object(new C(len)) : new Array(len); // 16. Let k be 0. var k = 0; // 17. Repeat, while k < len… (also steps a - h) var kValue; while (k < len) { kValue = items[k]; if (mapFn) { A[k] = mapFn(kValue, k); } else { A[k] = kValue; } k += 1; } // 18. Let putStatus be Put(A, "length", len, true). A.length = len; // 20. Return A. return A; } function _classCallCheck(instance, Constructor) { if (!(instance instanceof Constructor)) { throw new TypeError("Cannot call a class as a function"); } } function _defineProperties(target, props) { for (var i = 0; i < props.length; i++) { var descriptor = props[i]; descriptor.enumerable = descriptor.enumerable || false; descriptor.configurable = true; if ("value" in descriptor) descriptor.writable = true; Object.defineProperty(target, descriptor.key, descriptor); } } function _createClass(Constructor, protoProps, staticProps) { if (protoProps) _defineProperties(Constructor.prototype, protoProps); if (staticProps) _defineProperties(Constructor, staticProps); Object.defineProperty(Constructor, "prototype", { writable: false }); return Constructor; } function _defineProperty$2(obj, key, value) { if (key in obj) { Object.defineProperty(obj, key, { value: value, enumerable: true, configurable: true, writable: true }); } else { obj[key] = value; } return obj; } // for environments without Set we fallback to arrays with unique members var SetLike = /*#__PURE__*/function () { function SetLike() { var items = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : []; _classCallCheck(this, SetLike); _defineProperty$2(this, "items", void 0); this.items = items; } _createClass(SetLike, [{ key: "add", value: function add(value) { if (this.has(value) === false) { this.items.push(value); } return this; } }, { key: "clear", value: function clear() { this.items = []; } }, { key: "delete", value: function _delete(value) { var previousLength = this.items.length; this.items = this.items.filter(function (item) { return item !== value; }); return previousLength !== this.items.length; } }, { key: "forEach", value: function forEach(callbackfn) { var _this = this; this.items.forEach(function (item) { callbackfn(item, item, _this); }); } }, { key: "has", value: function has(value) { return this.items.indexOf(value) !== -1; } }, { key: "size", get: function get() { return this.items.length; } }]); return SetLike; }(); var SetLike$1 = typeof Set === "undefined" ? Set : SetLike; // https://w3c.github.io/html-aria/#document-conformance-requirements-for-use-of-aria-attributes-in-html /** * Safe Element.localName for all supported environments * @param element */ function getLocalName(element) { var _element$localName; return (// eslint-disable-next-line no-restricted-properties -- actual guard for environments without localName (_element$localName = element.localName) !== null && _element$localName !== void 0 ? _element$localName : // eslint-disable-next-line no-restricted-properties -- required for the fallback element.tagName.toLowerCase() ); } var localNameToRoleMappings = { article: "article", aside: "complementary", button: "button", datalist: "listbox", dd: "definition", details: "group", dialog: "dialog", dt: "term", fieldset: "group", figure: "figure", // WARNING: Only with an accessible name form: "form", footer: "contentinfo", h1: "heading", h2: "heading", h3: "heading", h4: "heading", h5: "heading", h6: "heading", header: "banner", hr: "separator", html: "document", legend: "legend", li: "listitem", math: "math", main: "main", menu: "list", nav: "navigation", ol: "list", optgroup: "group", // WARNING: Only in certain context option: "option", output: "status", progress: "progressbar", // WARNING: Only with an accessible name section: "region", summary: "button", table: "table", tbody: "rowgroup", textarea: "textbox", tfoot: "rowgroup", // WARNING: Only in certain context td: "cell", th: "columnheader", thead: "rowgroup", tr: "row", ul: "list" }; var prohibitedAttributes = { caption: new Set(["aria-label", "aria-labelledby"]), code: new Set(["aria-label", "aria-labelledby"]), deletion: new Set(["aria-label", "aria-labelledby"]), emphasis: new Set(["aria-label", "aria-labelledby"]), generic: new Set(["aria-label", "aria-labelledby", "aria-roledescription"]), insertion: new Set(["aria-label", "aria-labelledby"]), paragraph: new Set(["aria-label", "aria-labelledby"]), presentation: new Set(["aria-label", "aria-labelledby"]), strong: new Set(["aria-label", "aria-labelledby"]), subscript: new Set(["aria-label", "aria-labelledby"]), superscript: new Set(["aria-label", "aria-labelledby"]) }; /** * * @param element * @param role The role used for this element. This is specified to control whether you want to use the implicit or explicit role. */ function hasGlobalAriaAttributes(element, role) { // https://rawgit.com/w3c/aria/stable/#global_states // commented attributes are deprecated return ["aria-atomic", "aria-busy", "aria-controls", "aria-current", "aria-describedby", "aria-details", // "disabled", "aria-dropeffect", // "errormessage", "aria-flowto", "aria-grabbed", // "haspopup", "aria-hidden", // "invalid", "aria-keyshortcuts", "aria-label", "aria-labelledby", "aria-live", "aria-owns", "aria-relevant", "aria-roledescription"].some(function (attributeName) { var _prohibitedAttributes; return element.hasAttribute(attributeName) && !((_prohibitedAttributes = prohibitedAttributes[role]) !== null && _prohibitedAttributes !== void 0 && _prohibitedAttributes.has(attributeName)); }); } function ignorePresentationalRole(element, implicitRole) { // https://rawgit.com/w3c/aria/stable/#conflict_resolution_presentation_none return hasGlobalAriaAttributes(element, implicitRole); } function getRole(element) { var explicitRole = getExplicitRole(element); if (explicitRole === null || explicitRole === "presentation") { var implicitRole = getImplicitRole(element); if (explicitRole !== "presentation" || ignorePresentationalRole(element, implicitRole || "")) { return implicitRole; } } return explicitRole; } function getImplicitRole(element) { var mappedByTag = localNameToRoleMappings[getLocalName(element)]; if (mappedByTag !== undefined) { return mappedByTag; } switch (getLocalName(element)) { case "a": case "area": case "link": if (element.hasAttribute("href")) { return "link"; } break; case "img": if (element.getAttribute("alt") === "" && !ignorePresentationalRole(element, "img")) { return "presentation"; } return "img"; case "input": { var _ref = element, type = _ref.type; switch (type) { case "button": case "image": case "reset": case "submit": return "button"; case "checkbox": case "radio": return type; case "range": return "slider"; case "email": case "tel": case "text": case "url": if (element.hasAttribute("list")) { return "combobox"; } return "textbox"; case "search": if (element.hasAttribute("list")) { return "combobox"; } return "searchbox"; case "number": return "spinbutton"; default: return null; } } case "select": if (element.hasAttribute("multiple") || element.size > 1) { return "listbox"; } return "combobox"; } return null; } function getExplicitRole(element) { var role = element.getAttribute("role"); if (role !== null) { var explicitRole = role.trim().split(" ")[0]; // String.prototype.split(sep, limit) will always return an array with at least one member // as long as limit is either undefined or > 0 if (explicitRole.length > 0) { return explicitRole; } } return null; } function isElement(node) { return node !== null && node.nodeType === node.ELEMENT_NODE; } function isHTMLTableCaptionElement(node) { return isElement(node) && getLocalName(node) === "caption"; } function isHTMLInputElement(node) { return isElement(node) && getLocalName(node) === "input"; } function isHTMLOptGroupElement(node) { return isElement(node) && getLocalName(node) === "optgroup"; } function isHTMLSelectElement(node) { return isElement(node) && getLocalName(node) === "select"; } function isHTMLTableElement(node) { return isElement(node) && getLocalName(node) === "table"; } function isHTMLTextAreaElement(node) { return isElement(node) && getLocalName(node) === "textarea"; } function safeWindow(node) { var _ref = node.ownerDocument === null ? node : node.ownerDocument, defaultView = _ref.defaultView; if (defaultView === null) { throw new TypeError("no window available"); } return defaultView; } function isHTMLFieldSetElement(node) { return isElement(node) && getLocalName(node) === "fieldset"; } function isHTMLLegendElement(node) { return isElement(node) && getLocalName(node) === "legend"; } function isHTMLSlotElement(node) { return isElement(node) && getLocalName(node) === "slot"; } function isSVGElement(node) { return isElement(node) && node.ownerSVGElement !== undefined; } function isSVGSVGElement(node) { return isElement(node) && getLocalName(node) === "svg"; } function isSVGTitleElement(node) { return isSVGElement(node) && getLocalName(node) === "title"; } /** * * @param {Node} node - * @param {string} attributeName - * @returns {Element[]} - */ function queryIdRefs(node, attributeName) { if (isElement(node) && node.hasAttribute(attributeName)) { // eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- safe due to hasAttribute check var ids = node.getAttribute(attributeName).split(" "); // Browsers that don't support shadow DOM won't have getRootNode var root = node.getRootNode ? node.getRootNode() : node.ownerDocument; return ids.map(function (id) { return root.getElementById(id); }).filter(function (element) { return element !== null; } // TODO: why does this not narrow? ); } return []; } function hasAnyConcreteRoles(node, roles) { if (isElement(node)) { return roles.indexOf(getRole(node)) !== -1; } return false; } /** * implements https://w3c.github.io/accname/ */ /** * A string of characters where all carriage returns, newlines, tabs, and form-feeds are replaced with a single space, and multiple spaces are reduced to a single space. The string contains only character data; it does not contain any markup. */ /** * * @param {string} string - * @returns {FlatString} - */ function asFlatString(s) { return s.trim().replace(/\s\s+/g, " "); } /** * * @param node - * @param options - These are not optional to prevent accidentally calling it without options in `computeAccessibleName` * @returns {boolean} - */ function isHidden(node, getComputedStyleImplementation) { if (!isElement(node)) { return false; } if (node.hasAttribute("hidden") || node.getAttribute("aria-hidden") === "true") { return true; } var style = getComputedStyleImplementation(node); return style.getPropertyValue("display") === "none" || style.getPropertyValue("visibility") === "hidden"; } /** * @param {Node} node - * @returns {boolean} - As defined in step 2E of https://w3c.github.io/accname/#mapping_additional_nd_te */ function isControl(node) { return hasAnyConcreteRoles(node, ["button", "combobox", "listbox", "textbox"]) || hasAbstractRole(node, "range"); } function hasAbstractRole(node, role) { if (!isElement(node)) { return false; } switch (role) { case "range": return hasAnyConcreteRoles(node, ["meter", "progressbar", "scrollbar", "slider", "spinbutton"]); default: throw new TypeError("No knowledge about abstract role '".concat(role, "'. This is likely a bug :(")); } } /** * element.querySelectorAll but also considers owned tree * @param element * @param selectors */ function querySelectorAllSubtree(element, selectors) { var elements = arrayFrom(element.querySelectorAll(selectors)); queryIdRefs(element, "aria-owns").forEach(function (root) { // babel transpiles this assuming an iterator elements.push.apply(elements, arrayFrom(root.querySelectorAll(selectors))); }); return elements; } function querySelectedOptions(listbox) { if (isHTMLSelectElement(listbox)) { // IE11 polyfill return listbox.selectedOptions || querySelectorAllSubtree(listbox, "[selected]"); } return querySelectorAllSubtree(listbox, '[aria-selected="true"]'); } function isMarkedPresentational(node) { return hasAnyConcreteRoles(node, ["none", "presentation"]); } /** * Elements specifically listed in html-aam * * We don't need this for `label` or `legend` elements. * Their implicit roles already allow "naming from content". * * sources: * * - https://w3c.github.io/html-aam/#table-element */ function isNativeHostLanguageTextAlternativeElement(node) { return isHTMLTableCaptionElement(node); } /** * https://w3c.github.io/aria/#namefromcontent */ function allowsNameFromContent(node) { return hasAnyConcreteRoles(node, ["button", "cell", "checkbox", "columnheader", "gridcell", "heading", "label", "legend", "link", "menuitem", "menuitemcheckbox", "menuitemradio", "option", "radio", "row", "rowheader", "switch", "tab", "tooltip", "treeitem"]); } /** * TODO https://github.com/eps1lon/dom-accessibility-api/issues/100 */ function isDescendantOfNativeHostLanguageTextAlternativeElement( // eslint-disable-next-line @typescript-eslint/no-unused-vars -- not implemented yet node) { return false; } function getValueOfTextbox(element) { if (isHTMLInputElement(element) || isHTMLTextAreaElement(element)) { return element.value; } // https://github.com/eps1lon/dom-accessibility-api/issues/4 return element.textContent || ""; } function getTextualContent(declaration) { var content = declaration.getPropertyValue("content"); if (/^["'].*["']$/.test(content)) { return content.slice(1, -1); } return ""; } /** * https://html.spec.whatwg.org/multipage/forms.html#category-label * TODO: form-associated custom elements * @param element */ function isLabelableElement(element) { var localName = getLocalName(element); return localName === "button" || localName === "input" && element.getAttribute("type") !== "hidden" || localName === "meter" || localName === "output" || localName === "progress" || localName === "select" || localName === "textarea"; } /** * > [...], then the first such descendant in tree order is the label element's labeled control. * -- https://html.spec.whatwg.org/multipage/forms.html#labeled-control * @param element */ function findLabelableElement(element) { if (isLabelableElement(element)) { return element; } var labelableElement = null; element.childNodes.forEach(function (childNode) { if (labelableElement === null && isElement(childNode)) { var descendantLabelableElement = findLabelableElement(childNode); if (descendantLabelableElement !== null) { labelableElement = descendantLabelableElement; } } }); return labelableElement; } /** * Polyfill of HTMLLabelElement.control * https://html.spec.whatwg.org/multipage/forms.html#labeled-control * @param label */ function getControlOfLabel(label) { if (label.control !== undefined) { return label.control; } var htmlFor = label.getAttribute("for"); if (htmlFor !== null) { return label.ownerDocument.getElementById(htmlFor); } return findLabelableElement(label); } /** * Polyfill of HTMLInputElement.labels * https://developer.mozilla.org/en-US/docs/Web/API/HTMLInputElement/labels * @param element */ function getLabels(element) { var labelsProperty = element.labels; if (labelsProperty === null) { return labelsProperty; } if (labelsProperty !== undefined) { return arrayFrom(labelsProperty); } // polyfill if (!isLabelableElement(element)) { return null; } var document = element.ownerDocument; return arrayFrom(document.querySelectorAll("label")).filter(function (label) { return getControlOfLabel(label) === element; }); } /** * Gets the contents of a slot used for computing the accname * @param slot */ function getSlotContents(slot) { // Computing the accessible name for elements containing slots is not // currently defined in the spec. This implementation reflects the // behavior of NVDA 2020.2/Firefox 81 and iOS VoiceOver/Safari 13.6. var assignedNodes = slot.assignedNodes(); if (assignedNodes.length === 0) { // if no nodes are assigned to the slot, it displays the default content return arrayFrom(slot.childNodes); } return assignedNodes; } /** * implements https://w3c.github.io/accname/#mapping_additional_nd_te * @param root * @param options * @returns */ function computeTextAlternative(root) { var options = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : {}; var consultedNodes = new SetLike$1(); var window = safeWindow(root); var _options$compute = options.compute, compute = _options$compute === void 0 ? "name" : _options$compute, _options$computedStyl = options.computedStyleSupportsPseudoElements, computedStyleSupportsPseudoElements = _options$computedStyl === void 0 ? options.getComputedStyle !== undefined : _options$computedStyl, _options$getComputedS = options.getComputedStyle, getComputedStyle = _options$getComputedS === void 0 ? window.getComputedStyle.bind(window) : _options$getComputedS, _options$hidden = options.hidden, hidden = _options$hidden === void 0 ? false : _options$hidden; // 2F.i function computeMiscTextAlternative(node, context) { var accumulatedText = ""; if (isElement(node) && computedStyleSupportsPseudoElements) { var pseudoBefore = getComputedStyle(node, "::before"); var beforeContent = getTextualContent(pseudoBefore); accumulatedText = "".concat(beforeContent, " ").concat(accumulatedText); } // FIXME: Including aria-owns is not defined in the spec // But it is required in the web-platform-test var childNodes = isHTMLSlotElement(node) ? getSlotContents(node) : arrayFrom(node.childNodes).concat(queryIdRefs(node, "aria-owns")); childNodes.forEach(function (child) { var result = computeTextAlternative(child, { isEmbeddedInLabel: context.isEmbeddedInLabel, isReferenced: false, recursion: true }); // TODO: Unclear why display affects delimiter // see https://github.com/w3c/accname/issues/3 var display = isElement(child) ? getComputedStyle(child).getPropertyValue("display") : "inline"; var separator = display !== "inline" ? " " : ""; // trailing separator for wpt tests accumulatedText += "".concat(separator).concat(result).concat(separator); }); if (isElement(node) && computedStyleSupportsPseudoElements) { var pseudoAfter = getComputedStyle(node, "::after"); var afterContent = getTextualContent(pseudoAfter); accumulatedText = "".concat(accumulatedText, " ").concat(afterContent); } return accumulatedText.trim(); } function computeElementTextAlternative(node) { if (!isElement(node)) { return null; } /** * * @param element * @param attributeName * @returns A string non-empty string or `null` */ function useAttribute(element, attributeName) { var attribute = element.getAttributeNode(attributeName); if (attribute !== null && !consultedNodes.has(attribute) && attribute.value.trim() !== "") { consultedNodes.add(attribute); return attribute.value; } return null; } // https://w3c.github.io/html-aam/#fieldset-and-legend-elements if (isHTMLFieldSetElement(node)) { consultedNodes.add(node); var children = arrayFrom(node.childNodes); for (var i = 0; i < children.length; i += 1) { var child = children[i]; if (isHTMLLegendElement(child)) { return computeTextAlternative(child, { isEmbeddedInLabel: false, isReferenced: false, recursion: false }); } } } else if (isHTMLTableElement(node)) { // https://w3c.github.io/html-aam/#table-element consultedNodes.add(node); var _children = arrayFrom(node.childNodes); for (var _i = 0; _i < _children.length; _i += 1) { var _child = _children[_i]; if (isHTMLTableCaptionElement(_child)) { return computeTextAlternative(_child, { isEmbeddedInLabel: false, isReferenced: false, recursion: false }); } } } else if (isSVGSVGElement(node)) { // https://www.w3.org/TR/svg-aam-1.0/ consultedNodes.add(node); var _children2 = arrayFrom(node.childNodes); for (var _i2 = 0; _i2 < _children2.length; _i2 += 1) { var _child2 = _children2[_i2]; if (isSVGTitleElement(_child2)) { return _child2.textContent; } } return null; } else if (getLocalName(node) === "img" || getLocalName(node) === "area") { // https://w3c.github.io/html-aam/#area-element // https://w3c.github.io/html-aam/#img-element var nameFromAlt = useAttribute(node, "alt"); if (nameFromAlt !== null) { return nameFromAlt; } } else if (isHTMLOptGroupElement(node)) { var nameFromLabel = useAttribute(node, "label"); if (nameFromLabel !== null) { return nameFromLabel; } } if (isHTMLInputElement(node) && (node.type === "button" || node.type === "submit" || node.type === "reset")) { // https://w3c.github.io/html-aam/#input-type-text-input-type-password-input-type-search-input-type-tel-input-type-email-input-type-url-and-textarea-element-accessible-description-computation var nameFromValue = useAttribute(node, "value"); if (nameFromValue !== null) { return nameFromValue; } // TODO: l10n if (node.type === "submit") { return "Submit"; } // TODO: l10n if (node.type === "reset") { return "Reset"; } } var labels = getLabels(node); if (labels !== null && labels.length !== 0) { consultedNodes.add(node); return arrayFrom(labels).map(function (element) { return computeTextAlternative(element, { isEmbeddedInLabel: true, isReferenced: false, recursion: true }); }).filter(function (label) { return label.length > 0; }).join(" "); } // https://w3c.github.io/html-aam/#input-type-image-accessible-name-computation // TODO: wpt test consider label elements but html-aam does not mention them // We follow existing implementations over spec if (isHTMLInputElement(node) && node.type === "image") { var _nameFromAlt = useAttribute(node, "alt"); if (_nameFromAlt !== null) { return _nameFromAlt; } var nameFromTitle = useAttribute(node, "title"); if (nameFromTitle !== null) { return nameFromTitle; } // TODO: l10n return "Submit Query"; } if (hasAnyConcreteRoles(node, ["button"])) { // https://www.w3.org/TR/html-aam-1.0/#button-element var nameFromSubTree = computeMiscTextAlternative(node, { isEmbeddedInLabel: false, isReferenced: false }); if (nameFromSubTree !== "") { return nameFromSubTree; } return useAttribute(node, "title"); } return useAttribute(node, "title"); } function computeTextAlternative(current, context) { if (consultedNodes.has(current)) { return ""; } // 2A if (!hidden && isHidden(current, getComputedStyle) && !context.isReferenced) { consultedNodes.add(current); return ""; } // 2B var labelElements = queryIdRefs(current, "aria-labelledby"); if (compute === "name" && !context.isReferenced && labelElements.length > 0) { return labelElements.map(function (element) { return computeTextAlternative(element, { isEmbeddedInLabel: context.isEmbeddedInLabel, isReferenced: true, // thais isn't recursion as specified, otherwise we would skip // `aria-label` in // 1 && arguments[1] !== undefined ? arguments[1] : {}; var description = queryIdRefs(root, "aria-describedby").map(function (element) { return computeTextAlternative(element, _objectSpread(_objectSpread({}, options), {}, { compute: "description" })); }).join(" "); // TODO: Technically we need to make sure that node wasn't used for the accessible name // This causes `description_1.0_combobox-focusable-manual` to fail // // https://www.w3.org/TR/html-aam-1.0/#accessible-name-and-description-computation // says for so many elements to use the `title` that we assume all elements are considered if (description === "") { var title = root.getAttribute("title"); description = title === null ? "" : title; } return description; } /** * https://w3c.github.io/aria/#namefromprohibited */ function prohibitsNaming(node) { return hasAnyConcreteRoles(node, ["caption", "code", "deletion", "emphasis", "generic", "insertion", "paragraph", "presentation", "strong", "subscript", "superscript"]); } /** * implements https://w3c.github.io/accname/#mapping_additional_nd_name * @param root * @param options * @returns */ function computeAccessibleName(root) { var options = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : {}; if (prohibitsNaming(root)) { return ""; } return computeTextAlternative(root, options); } var lib = {}; var ariaPropsMap$1 = {}; Object.defineProperty(ariaPropsMap$1, "__esModule", { value: true }); ariaPropsMap$1.default = void 0; function _slicedToArray$4(arr, i) { return _arrayWithHoles$4(arr) || _iterableToArrayLimit$4(arr, i) || _unsupportedIterableToArray$4(arr, i) || _nonIterableRest$4(); } function _nonIterableRest$4() { throw new TypeError("Invalid attempt to destructure non-iterable instance.\nIn order to be iterable, non-array objects must have a [Symbol.iterator]() method."); } function _unsupportedIterableToArray$4(o, minLen) { if (!o) return; if (typeof o === "string") return _arrayLikeToArray$4(o, minLen); var n = Object.prototype.toString.call(o).slice(8, -1); if (n === "Object" && o.constructor) n = o.constructor.name; if (n === "Map" || n === "Set") return Array.from(o); if (n === "Arguments" || /^(?:Ui|I)nt(?:8|16|32)(?:Clamped)?Array$/.test(n)) return _arrayLikeToArray$4(o, minLen); } function _arrayLikeToArray$4(arr, len) { if (len == null || len > arr.length) len = arr.length; for (var i = 0, arr2 = new Array(len); i < len; i++) { arr2[i] = arr[i]; } return arr2; } function _iterableToArrayLimit$4(arr, i) { var _i = arr == null ? null : typeof Symbol !== "undefined" && arr[Symbol.iterator] || arr["@@iterator"]; if (_i == null) return; var _arr = []; var _n = true; var _d = false; var _s, _e; try { for (_i = _i.call(arr); !(_n = (_s = _i.next()).done); _n = true) { _arr.push(_s.value); if (i && _arr.length === i) break; } } catch (err) { _d = true; _e = err; } finally { try { if (!_n && _i["return"] != null) _i["return"](); } finally { if (_d) throw _e; } } return _arr; } function _arrayWithHoles$4(arr) { if (Array.isArray(arr)) return arr; } var properties = [['aria-activedescendant', { 'type': 'id' }], ['aria-atomic', { 'type': 'boolean' }], ['aria-autocomplete', { 'type': 'token', 'values': ['inline', 'list', 'both', 'none'] }], ['aria-busy', { 'type': 'boolean' }], ['aria-checked', { 'type': 'tristate' }], ['aria-colcount', { type: 'integer' }], ['aria-colindex', { type: 'integer' }], ['aria-colspan', { type: 'integer' }], ['aria-controls', { 'type': 'idlist' }], ['aria-current', { type: 'token', values: ['page', 'step', 'location', 'date', 'time', true, false] }], ['aria-describedby', { 'type': 'idlist' }], ['aria-details', { 'type': 'id' }], ['aria-disabled', { 'type': 'boolean' }], ['aria-dropeffect', { 'type': 'tokenlist', 'values': ['copy', 'execute', 'link', 'move', 'none', 'popup'] }], ['aria-errormessage', { 'type': 'id' }], ['aria-expanded', { 'type': 'boolean', 'allowundefined': true }], ['aria-flowto', { 'type': 'idlist' }], ['aria-grabbed', { 'type': 'boolean', 'allowundefined': true }], ['aria-haspopup', { 'type': 'token', 'values': [false, true, 'menu', 'listbox', 'tree', 'grid', 'dialog'] }], ['aria-hidden', { 'type': 'boolean', 'allowundefined': true }], ['aria-invalid', { 'type': 'token', 'values': ['grammar', false, 'spelling', true] }], ['aria-keyshortcuts', { type: 'string' }], ['aria-label', { 'type': 'string' }], ['aria-labelledby', { 'type': 'idlist' }], ['aria-level', { 'type': 'integer' }], ['aria-live', { 'type': 'token', 'values': ['assertive', 'off', 'polite'] }], ['aria-modal', { type: 'boolean' }], ['aria-multiline', { 'type': 'boolean' }], ['aria-multiselectable', { 'type': 'boolean' }], ['aria-orientation', { 'type': 'token', 'values': ['vertical', 'undefined', 'horizontal'] }], ['aria-owns', { 'type': 'idlist' }], ['aria-placeholder', { type: 'string' }], ['aria-posinset', { 'type': 'integer' }], ['aria-pressed', { 'type': 'tristate' }], ['aria-readonly', { 'type': 'boolean' }], ['aria-relevant', { 'type': 'tokenlist', 'values': ['additions', 'all', 'removals', 'text'] }], ['aria-required', { 'type': 'boolean' }], ['aria-roledescription', { type: 'string' }], ['aria-rowcount', { type: 'integer' }], ['aria-rowindex', { type: 'integer' }], ['aria-rowspan', { type: 'integer' }], ['aria-selected', { 'type': 'boolean', 'allowundefined': true }], ['aria-setsize', { 'type': 'integer' }], ['aria-sort', { 'type': 'token', 'values': ['ascending', 'descending', 'none', 'other'] }], ['aria-valuemax', { 'type': 'number' }], ['aria-valuemin', { 'type': 'number' }], ['aria-valuenow', { 'type': 'number' }], ['aria-valuetext', { 'type': 'string' }]]; var ariaPropsMap = { entries: function entries() { return properties; }, get: function get(key) { var item = properties.find(function (tuple) { return tuple[0] === key ? true : false; }); return item && item[1]; }, has: function has(key) { return !!this.get(key); }, keys: function keys() { return properties.map(function (_ref) { var _ref2 = _slicedToArray$4(_ref, 1), key = _ref2[0]; return key; }); }, values: function values() { return properties.map(function (_ref3) { var _ref4 = _slicedToArray$4(_ref3, 2), values = _ref4[1]; return values; }); } }; var _default$2c = ariaPropsMap; ariaPropsMap$1.default = _default$2c; var domMap$1 = {}; Object.defineProperty(domMap$1, "__esModule", { value: true }); domMap$1.default = void 0; function _slicedToArray$3(arr, i) { return _arrayWithHoles$3(arr) || _iterableToArrayLimit$3(arr, i) || _unsupportedIterableToArray$3(arr, i) || _nonIterableRest$3(); } function _nonIterableRest$3() { throw new TypeError("Invalid attempt to destructure non-iterable instance.\nIn order to be iterable, non-array objects must have a [Symbol.iterator]() method."); } function _unsupportedIterableToArray$3(o, minLen) { if (!o) return; if (typeof o === "string") return _arrayLikeToArray$3(o, minLen); var n = Object.prototype.toString.call(o).slice(8, -1); if (n === "Object" && o.constructor) n = o.constructor.name; if (n === "Map" || n === "Set") return Array.from(o); if (n === "Arguments" || /^(?:Ui|I)nt(?:8|16|32)(?:Clamped)?Array$/.test(n)) return _arrayLikeToArray$3(o, minLen); } function _arrayLikeToArray$3(arr, len) { if (len == null || len > arr.length) len = arr.length; for (var i = 0, arr2 = new Array(len); i < len; i++) { arr2[i] = arr[i]; } return arr2; } function _iterableToArrayLimit$3(arr, i) { var _i = arr == null ? null : typeof Symbol !== "undefined" && arr[Symbol.iterator] || arr["@@iterator"]; if (_i == null) return; var _arr = []; var _n = true; var _d = false; var _s, _e; try { for (_i = _i.call(arr); !(_n = (_s = _i.next()).done); _n = true) { _arr.push(_s.value); if (i && _arr.length === i) break; } } catch (err) { _d = true; _e = err; } finally { try { if (!_n && _i["return"] != null) _i["return"](); } finally { if (_d) throw _e; } } return _arr; } function _arrayWithHoles$3(arr) { if (Array.isArray(arr)) return arr; } var dom$1 = [['a', { reserved: false }], ['abbr', { reserved: false }], ['acronym', { reserved: false }], ['address', { reserved: false }], ['applet', { reserved: false }], ['area', { reserved: false }], ['article', { reserved: false }], ['aside', { reserved: false }], ['audio', { reserved: false }], ['b', { reserved: false }], ['base', { reserved: true }], ['bdi', { reserved: false }], ['bdo', { reserved: false }], ['big', { reserved: false }], ['blink', { reserved: false }], ['blockquote', { reserved: false }], ['body', { reserved: false }], ['br', { reserved: false }], ['button', { reserved: false }], ['canvas', { reserved: false }], ['caption', { reserved: false }], ['center', { reserved: false }], ['cite', { reserved: false }], ['code', { reserved: false }], ['col', { reserved: true }], ['colgroup', { reserved: true }], ['content', { reserved: false }], ['data', { reserved: false }], ['datalist', { reserved: false }], ['dd', { reserved: false }], ['del', { reserved: false }], ['details', { reserved: false }], ['dfn', { reserved: false }], ['dialog', { reserved: false }], ['dir', { reserved: false }], ['div', { reserved: false }], ['dl', { reserved: false }], ['dt', { reserved: false }], ['em', { reserved: false }], ['embed', { reserved: false }], ['fieldset', { reserved: false }], ['figcaption', { reserved: false }], ['figure', { reserved: false }], ['font', { reserved: false }], ['footer', { reserved: false }], ['form', { reserved: false }], ['frame', { reserved: false }], ['frameset', { reserved: false }], ['h1', { reserved: false }], ['h2', { reserved: false }], ['h3', { reserved: false }], ['h4', { reserved: false }], ['h5', { reserved: false }], ['h6', { reserved: false }], ['head', { reserved: true }], ['header', { reserved: false }], ['hgroup', { reserved: false }], ['hr', { reserved: false }], ['html', { reserved: true }], ['i', { reserved: false }], ['iframe', { reserved: false }], ['img', { reserved: false }], ['input', { reserved: false }], ['ins', { reserved: false }], ['kbd', { reserved: false }], ['keygen', { reserved: false }], ['label', { reserved: false }], ['legend', { reserved: false }], ['li', { reserved: false }], ['link', { reserved: true }], ['main', { reserved: false }], ['map', { reserved: false }], ['mark', { reserved: false }], ['marquee', { reserved: false }], ['menu', { reserved: false }], ['menuitem', { reserved: false }], ['meta', { reserved: true }], ['meter', { reserved: false }], ['nav', { reserved: false }], ['noembed', { reserved: true }], ['noscript', { reserved: true }], ['object', { reserved: false }], ['ol', { reserved: false }], ['optgroup', { reserved: false }], ['option', { reserved: false }], ['output', { reserved: false }], ['p', { reserved: false }], ['param', { reserved: true }], ['picture', { reserved: true }], ['pre', { reserved: false }], ['progress', { reserved: false }], ['q', { reserved: false }], ['rp', { reserved: false }], ['rt', { reserved: false }], ['rtc', { reserved: false }], ['ruby', { reserved: false }], ['s', { reserved: false }], ['samp', { reserved: false }], ['script', { reserved: true }], ['section', { reserved: false }], ['select', { reserved: false }], ['small', { reserved: false }], ['source', { reserved: true }], ['spacer', { reserved: false }], ['span', { reserved: false }], ['strike', { reserved: false }], ['strong', { reserved: false }], ['style', { reserved: true }], ['sub', { reserved: false }], ['summary', { reserved: false }], ['sup', { reserved: false }], ['table', { reserved: false }], ['tbody', { reserved: false }], ['td', { reserved: false }], ['textarea', { reserved: false }], ['tfoot', { reserved: false }], ['th', { reserved: false }], ['thead', { reserved: false }], ['time', { reserved: false }], ['title', { reserved: true }], ['tr', { reserved: false }], ['track', { reserved: true }], ['tt', { reserved: false }], ['u', { reserved: false }], ['ul', { reserved: false }], ['var', { reserved: false }], ['video', { reserved: false }], ['wbr', { reserved: false }], ['xmp', { reserved: false }]]; var domMap = { entries: function entries() { return dom$1; }, get: function get(key) { var item = dom$1.find(function (tuple) { return tuple[0] === key ? true : false; }); return item && item[1]; }, has: function has(key) { return !!this.get(key); }, keys: function keys() { return dom$1.map(function (_ref) { var _ref2 = _slicedToArray$3(_ref, 1), key = _ref2[0]; return key; }); }, values: function values() { return dom$1.map(function (_ref3) { var _ref4 = _slicedToArray$3(_ref3, 2), values = _ref4[1]; return values; }); } }; var _default$2b = domMap; domMap$1.default = _default$2b; var rolesMap$1 = {}; var ariaAbstractRoles$1 = {}; var commandRole$1 = {}; Object.defineProperty(commandRole$1, "__esModule", { value: true }); commandRole$1.default = void 0; var commandRole = { abstract: true, accessibleNameRequired: false, baseConcepts: [], childrenPresentational: false, nameFrom: ['author'], prohibitedProps: [], props: {}, relatedConcepts: [{ concept: { name: 'menuitem' }, module: 'HTML' }], requireContextRole: [], requiredContextRole: [], requiredOwnedElements: [], requiredProps: {}, superClass: [['roletype', 'widget']] }; var _default$2a = commandRole; commandRole$1.default = _default$2a; var compositeRole$1 = {}; Object.defineProperty(compositeRole$1, "__esModule", { value: true }); compositeRole$1.default = void 0; var compositeRole = { abstract: true, accessibleNameRequired: false, baseConcepts: [], childrenPresentational: false, nameFrom: ['author'], prohibitedProps: [], props: { 'aria-activedescendant': null, 'aria-disabled': null }, relatedConcepts: [], requireContextRole: [], requiredContextRole: [], requiredOwnedElements: [], requiredProps: {}, superClass: [['roletype', 'widget']] }; var _default$29 = compositeRole; compositeRole$1.default = _default$29; var inputRole$1 = {}; Object.defineProperty(inputRole$1, "__esModule", { value: true }); inputRole$1.default = void 0; var inputRole = { abstract: true, accessibleNameRequired: false, baseConcepts: [], childrenPresentational: false, nameFrom: ['author'], prohibitedProps: [], props: { 'aria-disabled': null }, relatedConcepts: [{ concept: { name: 'input' }, module: 'XForms' }], requireContextRole: [], requiredContextRole: [], requiredOwnedElements: [], requiredProps: {}, superClass: [['roletype', 'widget']] }; var _default$28 = inputRole; inputRole$1.default = _default$28; var landmarkRole$1 = {}; Object.defineProperty(landmarkRole$1, "__esModule", { value: true }); landmarkRole$1.default = void 0; var landmarkRole = { abstract: true, accessibleNameRequired: false, baseConcepts: [], childrenPresentational: false, nameFrom: ['author'], prohibitedProps: [], props: {}, relatedConcepts: [], requireContextRole: [], requiredContextRole: [], requiredOwnedElements: [], requiredProps: {}, superClass: [['roletype', 'structure', 'section']] }; var _default$27 = landmarkRole; landmarkRole$1.default = _default$27; var rangeRole$1 = {}; Object.defineProperty(rangeRole$1, "__esModule", { value: true }); rangeRole$1.default = void 0; var rangeRole = { abstract: true, accessibleNameRequired: false, baseConcepts: [], childrenPresentational: false, nameFrom: ['author'], prohibitedProps: [], props: { 'aria-valuemax': null, 'aria-valuemin': null, 'aria-valuenow': null }, relatedConcepts: [], requireContextRole: [], requiredContextRole: [], requiredOwnedElements: [], requiredProps: {}, superClass: [['roletype', 'structure']] }; var _default$26 = rangeRole; rangeRole$1.default = _default$26; var roletypeRole$1 = {}; Object.defineProperty(roletypeRole$1, "__esModule", { value: true }); roletypeRole$1.default = void 0; var roletypeRole = { abstract: true, accessibleNameRequired: false, baseConcepts: [], childrenPresentational: false, nameFrom: [], prohibitedProps: [], props: { 'aria-atomic': null, 'aria-busy': null, 'aria-controls': null, 'aria-current': null, 'aria-describedby': null, 'aria-details': null, 'aria-dropeffect': null, 'aria-flowto': null, 'aria-grabbed': null, 'aria-hidden': null, 'aria-keyshortcuts': null, 'aria-label': null, 'aria-labelledby': null, 'aria-live': null, 'aria-owns': null, 'aria-relevant': null, 'aria-roledescription': null }, relatedConcepts: [{ concept: { name: 'rel' }, module: 'HTML' }, { concept: { name: 'role' }, module: 'XHTML' }, { concept: { name: 'type' }, module: 'Dublin Core' }], requireContextRole: [], requiredContextRole: [], requiredOwnedElements: [], requiredProps: {}, superClass: [] }; var _default$25 = roletypeRole; roletypeRole$1.default = _default$25; var sectionRole$1 = {}; Object.defineProperty(sectionRole$1, "__esModule", { value: true }); sectionRole$1.default = void 0; var sectionRole = { abstract: true, accessibleNameRequired: false, baseConcepts: [], childrenPresentational: false, nameFrom: [], prohibitedProps: [], props: {}, relatedConcepts: [{ concept: { name: 'frontmatter' }, module: 'DTB' }, { concept: { name: 'level' }, module: 'DTB' }, { concept: { name: 'level' }, module: 'SMIL' }], requireContextRole: [], requiredContextRole: [], requiredOwnedElements: [], requiredProps: {}, superClass: [['roletype', 'structure']] }; var _default$24 = sectionRole; sectionRole$1.default = _default$24; var sectionheadRole$1 = {}; Object.defineProperty(sectionheadRole$1, "__esModule", { value: true }); sectionheadRole$1.default = void 0; var sectionheadRole = { abstract: true, accessibleNameRequired: false, baseConcepts: [], childrenPresentational: false, nameFrom: ['author', 'contents'], prohibitedProps: [], props: {}, relatedConcepts: [], requireContextRole: [], requiredContextRole: [], requiredOwnedElements: [], requiredProps: {}, superClass: [['roletype', 'structure']] }; var _default$23 = sectionheadRole; sectionheadRole$1.default = _default$23; var selectRole$1 = {}; Object.defineProperty(selectRole$1, "__esModule", { value: true }); selectRole$1.default = void 0; var selectRole = { abstract: true, accessibleNameRequired: false, baseConcepts: [], childrenPresentational: false, nameFrom: ['author'], prohibitedProps: [], props: { 'aria-orientation': null }, relatedConcepts: [], requireContextRole: [], requiredContextRole: [], requiredOwnedElements: [], requiredProps: {}, superClass: [['roletype', 'widget', 'composite'], ['roletype', 'structure', 'section', 'group']] }; var _default$22 = selectRole; selectRole$1.default = _default$22; var structureRole$1 = {}; Object.defineProperty(structureRole$1, "__esModule", { value: true }); structureRole$1.default = void 0; var structureRole = { abstract: true, accessibleNameRequired: false, baseConcepts: [], childrenPresentational: false, nameFrom: [], prohibitedProps: [], props: {}, relatedConcepts: [], requireContextRole: [], requiredContextRole: [], requiredOwnedElements: [], requiredProps: {}, superClass: [['roletype']] }; var _default$21 = structureRole; structureRole$1.default = _default$21; var widgetRole$1 = {}; Object.defineProperty(widgetRole$1, "__esModule", { value: true }); widgetRole$1.default = void 0; var widgetRole = { abstract: true, accessibleNameRequired: false, baseConcepts: [], childrenPresentational: false, nameFrom: [], prohibitedProps: [], props: {}, relatedConcepts: [], requireContextRole: [], requiredContextRole: [], requiredOwnedElements: [], requiredProps: {}, superClass: [['roletype']] }; var _default$20 = widgetRole; widgetRole$1.default = _default$20; var windowRole$1 = {}; Object.defineProperty(windowRole$1, "__esModule", { value: true }); windowRole$1.default = void 0; var windowRole = { abstract: true, accessibleNameRequired: false, baseConcepts: [], childrenPresentational: false, nameFrom: ['author'], prohibitedProps: [], props: { 'aria-modal': null }, relatedConcepts: [], requireContextRole: [], requiredContextRole: [], requiredOwnedElements: [], requiredProps: {}, superClass: [['roletype']] }; var _default$1$ = windowRole; windowRole$1.default = _default$1$; Object.defineProperty(ariaAbstractRoles$1, "__esModule", { value: true }); ariaAbstractRoles$1.default = void 0; var _commandRole = _interopRequireDefault$6(commandRole$1); var _compositeRole = _interopRequireDefault$6(compositeRole$1); var _inputRole = _interopRequireDefault$6(inputRole$1); var _landmarkRole = _interopRequireDefault$6(landmarkRole$1); var _rangeRole = _interopRequireDefault$6(rangeRole$1); var _roletypeRole = _interopRequireDefault$6(roletypeRole$1); var _sectionRole = _interopRequireDefault$6(sectionRole$1); var _sectionheadRole = _interopRequireDefault$6(sectionheadRole$1); var _selectRole = _interopRequireDefault$6(selectRole$1); var _structureRole = _interopRequireDefault$6(structureRole$1); var _widgetRole = _interopRequireDefault$6(widgetRole$1); var _windowRole = _interopRequireDefault$6(windowRole$1); function _interopRequireDefault$6(obj) { return obj && obj.__esModule ? obj : { default: obj }; } var ariaAbstractRoles = [['command', _commandRole.default], ['composite', _compositeRole.default], ['input', _inputRole.default], ['landmark', _landmarkRole.default], ['range', _rangeRole.default], ['roletype', _roletypeRole.default], ['section', _sectionRole.default], ['sectionhead', _sectionheadRole.default], ['select', _selectRole.default], ['structure', _structureRole.default], ['widget', _widgetRole.default], ['window', _windowRole.default]]; var _default$1_ = ariaAbstractRoles; ariaAbstractRoles$1.default = _default$1_; var ariaLiteralRoles$1 = {}; var alertRole$1 = {}; Object.defineProperty(alertRole$1, "__esModule", { value: true }); alertRole$1.default = void 0; var alertRole = { abstract: false, accessibleNameRequired: false, baseConcepts: [], childrenPresentational: false, nameFrom: ['author'], prohibitedProps: [], props: { 'aria-atomic': 'true', 'aria-live': 'assertive' }, relatedConcepts: [{ concept: { name: 'alert' }, module: 'XForms' }], requireContextRole: [], requiredContextRole: [], requiredOwnedElements: [], requiredProps: {}, superClass: [['roletype', 'structure', 'section']] }; var _default$1Z = alertRole; alertRole$1.default = _default$1Z; var alertdialogRole$1 = {}; Object.defineProperty(alertdialogRole$1, "__esModule", { value: true }); alertdialogRole$1.default = void 0; var alertdialogRole = { abstract: false, accessibleNameRequired: true, baseConcepts: [], childrenPresentational: false, nameFrom: ['author'], prohibitedProps: [], props: {}, relatedConcepts: [{ concept: { name: 'alert' }, module: 'XForms' }], requireContextRole: [], requiredContextRole: [], requiredOwnedElements: [], requiredProps: {}, superClass: [['roletype', 'structure', 'section', 'alert'], ['roletype', 'window', 'dialog']] }; var _default$1Y = alertdialogRole; alertdialogRole$1.default = _default$1Y; var applicationRole$1 = {}; Object.defineProperty(applicationRole$1, "__esModule", { value: true }); applicationRole$1.default = void 0; var applicationRole = { abstract: false, accessibleNameRequired: true, baseConcepts: [], childrenPresentational: false, nameFrom: ['author'], prohibitedProps: [], props: { 'aria-activedescendant': null, 'aria-disabled': null, 'aria-errormessage': null, 'aria-expanded': null, 'aria-haspopup': null, 'aria-invalid': null }, relatedConcepts: [{ concept: { name: 'Device Independence Delivery Unit' } }], requireContextRole: [], requiredContextRole: [], requiredOwnedElements: [], requiredProps: {}, superClass: [['roletype', 'structure']] }; var _default$1X = applicationRole; applicationRole$1.default = _default$1X; var articleRole$1 = {}; Object.defineProperty(articleRole$1, "__esModule", { value: true }); articleRole$1.default = void 0; var articleRole = { abstract: false, accessibleNameRequired: false, baseConcepts: [], childrenPresentational: false, nameFrom: ['author'], prohibitedProps: [], props: { 'aria-posinset': null, 'aria-setsize': null }, relatedConcepts: [{ concept: { name: 'article' }, module: 'HTML' }], requireContextRole: [], requiredContextRole: [], requiredOwnedElements: [], requiredProps: {}, superClass: [['roletype', 'structure', 'document']] }; var _default$1W = articleRole; articleRole$1.default = _default$1W; var bannerRole$1 = {}; Object.defineProperty(bannerRole$1, "__esModule", { value: true }); bannerRole$1.default = void 0; var bannerRole = { abstract: false, accessibleNameRequired: false, baseConcepts: [], childrenPresentational: false, nameFrom: ['author'], prohibitedProps: [], props: {}, relatedConcepts: [{ concept: { constraints: ['direct descendant of document'], name: 'header' }, module: 'HTML' }], requireContextRole: [], requiredContextRole: [], requiredOwnedElements: [], requiredProps: {}, superClass: [['roletype', 'structure', 'section', 'landmark']] }; var _default$1V = bannerRole; bannerRole$1.default = _default$1V; var blockquoteRole$1 = {}; Object.defineProperty(blockquoteRole$1, "__esModule", { value: true }); blockquoteRole$1.default = void 0; var blockquoteRole = { abstract: false, accessibleNameRequired: false, baseConcepts: [], childrenPresentational: false, nameFrom: ['author'], prohibitedProps: [], props: {}, relatedConcepts: [], requireContextRole: [], requiredContextRole: [], requiredOwnedElements: [], requiredProps: {}, superClass: [['roletype', 'structure', 'section']] }; var _default$1U = blockquoteRole; blockquoteRole$1.default = _default$1U; var buttonRole$1 = {}; Object.defineProperty(buttonRole$1, "__esModule", { value: true }); buttonRole$1.default = void 0; var buttonRole = { abstract: false, accessibleNameRequired: true, baseConcepts: [], childrenPresentational: true, nameFrom: ['author', 'contents'], prohibitedProps: [], props: { 'aria-disabled': null, 'aria-expanded': null, 'aria-haspopup': null, 'aria-pressed': null }, relatedConcepts: [{ concept: { attributes: [{ constraints: ['set'], name: 'aria-pressed' }, { name: 'type', value: 'checkbox' }], name: 'input' }, module: 'HTML' }, { concept: { attributes: [{ name: 'aria-expanded', value: 'false' }], name: 'summary' }, module: 'HTML' }, { concept: { attributes: [{ name: 'aria-expanded', value: 'true' }], constraints: ['direct descendant of details element with the open attribute defined'], name: 'summary' }, module: 'HTML' }, { concept: { attributes: [{ name: 'type', value: 'button' }], name: 'input' }, module: 'HTML' }, { concept: { attributes: [{ name: 'type', value: 'image' }], name: 'input' }, module: 'HTML' }, { concept: { attributes: [{ name: 'type', value: 'reset' }], name: 'input' }, module: 'HTML' }, { concept: { attributes: [{ name: 'type', value: 'submit' }], name: 'input' }, module: 'HTML' }, { concept: { name: 'button' }, module: 'HTML' }, { concept: { name: 'trigger' }, module: 'XForms' }], requireContextRole: [], requiredContextRole: [], requiredOwnedElements: [], requiredProps: {}, superClass: [['roletype', 'widget', 'command']] }; var _default$1T = buttonRole; buttonRole$1.default = _default$1T; var captionRole$1 = {}; Object.defineProperty(captionRole$1, "__esModule", { value: true }); captionRole$1.default = void 0; var captionRole = { abstract: false, accessibleNameRequired: false, baseConcepts: [], childrenPresentational: false, nameFrom: ['prohibited'], prohibitedProps: ['aria-label', 'aria-labelledby'], props: {}, relatedConcepts: [], requireContextRole: ['figure', 'grid', 'table'], requiredContextRole: ['figure', 'grid', 'table'], requiredOwnedElements: [], requiredProps: {}, superClass: [['roletype', 'structure', 'section']] }; var _default$1S = captionRole; captionRole$1.default = _default$1S; var cellRole$1 = {}; Object.defineProperty(cellRole$1, "__esModule", { value: true }); cellRole$1.default = void 0; var cellRole = { abstract: false, accessibleNameRequired: false, baseConcepts: [], childrenPresentational: false, nameFrom: ['author', 'contents'], prohibitedProps: [], props: { 'aria-colindex': null, 'aria-colspan': null, 'aria-rowindex': null, 'aria-rowspan': null }, relatedConcepts: [{ concept: { constraints: ['descendant of table'], name: 'td' }, module: 'HTML' }], requireContextRole: ['row'], requiredContextRole: ['row'], requiredOwnedElements: [], requiredProps: {}, superClass: [['roletype', 'structure', 'section']] }; var _default$1R = cellRole; cellRole$1.default = _default$1R; var checkboxRole$1 = {}; Object.defineProperty(checkboxRole$1, "__esModule", { value: true }); checkboxRole$1.default = void 0; var checkboxRole = { abstract: false, accessibleNameRequired: true, baseConcepts: [], childrenPresentational: true, nameFrom: ['author', 'contents'], prohibitedProps: [], props: { 'aria-checked': null, 'aria-errormessage': null, 'aria-expanded': null, 'aria-invalid': null, 'aria-readonly': null, 'aria-required': null }, relatedConcepts: [{ concept: { attributes: [{ name: 'type', value: 'checkbox' }], name: 'input' }, module: 'HTML' }, { concept: { name: 'option' }, module: 'ARIA' }], requireContextRole: [], requiredContextRole: [], requiredOwnedElements: [], requiredProps: { 'aria-checked': null }, superClass: [['roletype', 'widget', 'input']] }; var _default$1Q = checkboxRole; checkboxRole$1.default = _default$1Q; var codeRole$1 = {}; Object.defineProperty(codeRole$1, "__esModule", { value: true }); codeRole$1.default = void 0; var codeRole = { abstract: false, accessibleNameRequired: false, baseConcepts: [], childrenPresentational: false, nameFrom: ['prohibited'], prohibitedProps: ['aria-label', 'aria-labelledby'], props: {}, relatedConcepts: [], requireContextRole: [], requiredContextRole: [], requiredOwnedElements: [], requiredProps: {}, superClass: [['roletype', 'structure', 'section']] }; var _default$1P = codeRole; codeRole$1.default = _default$1P; var columnheaderRole$1 = {}; Object.defineProperty(columnheaderRole$1, "__esModule", { value: true }); columnheaderRole$1.default = void 0; var columnheaderRole = { abstract: false, accessibleNameRequired: true, baseConcepts: [], childrenPresentational: false, nameFrom: ['author', 'contents'], prohibitedProps: [], props: { 'aria-sort': null }, relatedConcepts: [{ attributes: [{ name: 'scope', value: 'col' }], concept: { name: 'th' }, module: 'HTML' }], requireContextRole: ['row'], requiredContextRole: ['row'], requiredOwnedElements: [], requiredProps: {}, superClass: [['roletype', 'structure', 'section', 'cell'], ['roletype', 'structure', 'section', 'cell', 'gridcell'], ['roletype', 'widget', 'gridcell'], ['roletype', 'structure', 'sectionhead']] }; var _default$1O = columnheaderRole; columnheaderRole$1.default = _default$1O; var comboboxRole$1 = {}; Object.defineProperty(comboboxRole$1, "__esModule", { value: true }); comboboxRole$1.default = void 0; var comboboxRole = { abstract: false, accessibleNameRequired: true, baseConcepts: [], childrenPresentational: false, nameFrom: ['author'], prohibitedProps: [], props: { 'aria-activedescendant': null, 'aria-autocomplete': null, 'aria-errormessage': null, 'aria-invalid': null, 'aria-readonly': null, 'aria-required': null, 'aria-expanded': 'false', 'aria-haspopup': 'listbox' }, relatedConcepts: [{ concept: { attributes: [{ constraints: ['set'], name: 'list' }, { name: 'type', value: 'email' }], name: 'input' }, module: 'HTML' }, { concept: { attributes: [{ constraints: ['set'], name: 'list' }, { name: 'type', value: 'search' }], name: 'input' }, module: 'HTML' }, { concept: { attributes: [{ constraints: ['set'], name: 'list' }, { name: 'type', value: 'tel' }], name: 'input' }, module: 'HTML' }, { concept: { attributes: [{ constraints: ['set'], name: 'list' }, { name: 'type', value: 'text' }], name: 'input' }, module: 'HTML' }, { concept: { attributes: [{ constraints: ['set'], name: 'list' }, { name: 'type', value: 'url' }], name: 'input' }, module: 'HTML' }, { concept: { attributes: [{ constraints: ['set'], name: 'list' }, { name: 'type', value: 'url' }], name: 'input' }, module: 'HTML' }, { concept: { attributes: [{ constraints: ['undefined'], name: 'multiple' }, { constraints: ['undefined'], name: 'size' }], name: 'select' }, module: 'HTML' }, { concept: { attributes: [{ constraints: ['undefined'], name: 'multiple' }, { name: 'size', value: 1 }], name: 'select' }, module: 'HTML' }, { concept: { name: 'select' }, module: 'XForms' }], requireContextRole: [], requiredContextRole: [], requiredOwnedElements: [], requiredProps: { 'aria-controls': null, 'aria-expanded': 'false' }, superClass: [['roletype', 'widget', 'input']] }; var _default$1N = comboboxRole; comboboxRole$1.default = _default$1N; var complementaryRole$1 = {}; Object.defineProperty(complementaryRole$1, "__esModule", { value: true }); complementaryRole$1.default = void 0; var complementaryRole = { abstract: false, accessibleNameRequired: false, baseConcepts: [], childrenPresentational: false, nameFrom: ['author'], prohibitedProps: [], props: {}, relatedConcepts: [{ concept: { name: 'aside' }, module: 'HTML' }], requireContextRole: [], requiredContextRole: [], requiredOwnedElements: [], requiredProps: {}, superClass: [['roletype', 'structure', 'section', 'landmark']] }; var _default$1M = complementaryRole; complementaryRole$1.default = _default$1M; var contentinfoRole$1 = {}; Object.defineProperty(contentinfoRole$1, "__esModule", { value: true }); contentinfoRole$1.default = void 0; var contentinfoRole = { abstract: false, accessibleNameRequired: false, baseConcepts: [], childrenPresentational: false, nameFrom: ['author'], prohibitedProps: [], props: {}, relatedConcepts: [{ concept: { constraints: ['direct descendant of document'], name: 'footer' }, module: 'HTML' }], requireContextRole: [], requiredContextRole: [], requiredOwnedElements: [], requiredProps: {}, superClass: [['roletype', 'structure', 'section', 'landmark']] }; var _default$1L = contentinfoRole; contentinfoRole$1.default = _default$1L; var definitionRole$1 = {}; Object.defineProperty(definitionRole$1, "__esModule", { value: true }); definitionRole$1.default = void 0; var definitionRole = { abstract: false, accessibleNameRequired: false, baseConcepts: [], childrenPresentational: false, nameFrom: ['author'], prohibitedProps: [], props: {}, relatedConcepts: [{ concept: { name: 'dd' }, module: 'HTML' }], requireContextRole: [], requiredContextRole: [], requiredOwnedElements: [], requiredProps: {}, superClass: [['roletype', 'structure', 'section']] }; var _default$1K = definitionRole; definitionRole$1.default = _default$1K; var deletionRole$1 = {}; Object.defineProperty(deletionRole$1, "__esModule", { value: true }); deletionRole$1.default = void 0; var deletionRole = { abstract: false, accessibleNameRequired: false, baseConcepts: [], childrenPresentational: false, nameFrom: ['prohibited'], prohibitedProps: ['aria-label', 'aria-labelledby'], props: {}, relatedConcepts: [], requireContextRole: [], requiredContextRole: [], requiredOwnedElements: [], requiredProps: {}, superClass: [['roletype', 'structure', 'section']] }; var _default$1J = deletionRole; deletionRole$1.default = _default$1J; var dialogRole$1 = {}; Object.defineProperty(dialogRole$1, "__esModule", { value: true }); dialogRole$1.default = void 0; var dialogRole = { abstract: false, accessibleNameRequired: true, baseConcepts: [], childrenPresentational: false, nameFrom: ['author'], prohibitedProps: [], props: {}, relatedConcepts: [{ concept: { name: 'dialog' }, module: 'HTML' }], requireContextRole: [], requiredContextRole: [], requiredOwnedElements: [], requiredProps: {}, superClass: [['roletype', 'window']] }; var _default$1I = dialogRole; dialogRole$1.default = _default$1I; var directoryRole$1 = {}; Object.defineProperty(directoryRole$1, "__esModule", { value: true }); directoryRole$1.default = void 0; var directoryRole = { abstract: false, accessibleNameRequired: false, baseConcepts: [], childrenPresentational: false, nameFrom: ['author'], prohibitedProps: [], props: {}, relatedConcepts: [{ module: 'DAISY Guide' }], requireContextRole: [], requiredContextRole: [], requiredOwnedElements: [], requiredProps: {}, superClass: [['roletype', 'structure', 'section', 'list']] }; var _default$1H = directoryRole; directoryRole$1.default = _default$1H; var documentRole$1 = {}; Object.defineProperty(documentRole$1, "__esModule", { value: true }); documentRole$1.default = void 0; var documentRole = { abstract: false, accessibleNameRequired: false, baseConcepts: [], childrenPresentational: false, nameFrom: ['author'], prohibitedProps: [], props: {}, relatedConcepts: [{ concept: { name: 'Device Independence Delivery Unit' } }, { concept: { name: 'body' }, module: 'HTML' }], requireContextRole: [], requiredContextRole: [], requiredOwnedElements: [], requiredProps: {}, superClass: [['roletype', 'structure']] }; var _default$1G = documentRole; documentRole$1.default = _default$1G; var emphasisRole$1 = {}; Object.defineProperty(emphasisRole$1, "__esModule", { value: true }); emphasisRole$1.default = void 0; var emphasisRole = { abstract: false, accessibleNameRequired: false, baseConcepts: [], childrenPresentational: false, nameFrom: ['prohibited'], prohibitedProps: ['aria-label', 'aria-labelledby'], props: {}, relatedConcepts: [], requireContextRole: [], requiredContextRole: [], requiredOwnedElements: [], requiredProps: {}, superClass: [['roletype', 'structure', 'section']] }; var _default$1F = emphasisRole; emphasisRole$1.default = _default$1F; var feedRole$1 = {}; Object.defineProperty(feedRole$1, "__esModule", { value: true }); feedRole$1.default = void 0; var feedRole = { abstract: false, accessibleNameRequired: false, baseConcepts: [], childrenPresentational: false, nameFrom: ['author'], prohibitedProps: [], props: {}, relatedConcepts: [], requireContextRole: [], requiredContextRole: [], requiredOwnedElements: [['article']], requiredProps: {}, superClass: [['roletype', 'structure', 'section', 'list']] }; var _default$1E = feedRole; feedRole$1.default = _default$1E; var figureRole$1 = {}; Object.defineProperty(figureRole$1, "__esModule", { value: true }); figureRole$1.default = void 0; var figureRole = { abstract: false, accessibleNameRequired: false, baseConcepts: [], childrenPresentational: false, nameFrom: ['author'], prohibitedProps: [], props: {}, relatedConcepts: [{ concept: { name: 'figure' }, module: 'HTML' }], requireContextRole: [], requiredContextRole: [], requiredOwnedElements: [], requiredProps: {}, superClass: [['roletype', 'structure', 'section']] }; var _default$1D = figureRole; figureRole$1.default = _default$1D; var formRole$1 = {}; Object.defineProperty(formRole$1, "__esModule", { value: true }); formRole$1.default = void 0; var formRole = { abstract: false, accessibleNameRequired: false, baseConcepts: [], childrenPresentational: false, nameFrom: ['author'], prohibitedProps: [], props: {}, relatedConcepts: [{ concept: { attributes: [{ constraints: ['set'], name: 'aria-label' }], name: 'form' }, module: 'HTML' }, { concept: { attributes: [{ constraints: ['set'], name: 'aria-labelledby' }], name: 'form' }, module: 'HTML' }, { concept: { attributes: [{ constraints: ['set'], name: 'name' }], name: 'form' }, module: 'HTML' }], requireContextRole: [], requiredContextRole: [], requiredOwnedElements: [], requiredProps: {}, superClass: [['roletype', 'structure', 'section', 'landmark']] }; var _default$1C = formRole; formRole$1.default = _default$1C; var genericRole$1 = {}; Object.defineProperty(genericRole$1, "__esModule", { value: true }); genericRole$1.default = void 0; var genericRole = { abstract: false, accessibleNameRequired: false, baseConcepts: [], childrenPresentational: false, nameFrom: ['prohibited'], prohibitedProps: ['aria-label', 'aria-labelledby'], props: {}, relatedConcepts: [{ concept: { name: 'span' }, module: 'HTML' }, { concept: { name: 'div' }, module: 'HTML' }], requireContextRole: [], requiredContextRole: [], requiredOwnedElements: [], requiredProps: {}, superClass: [['roletype', 'structure']] }; var _default$1B = genericRole; genericRole$1.default = _default$1B; var gridRole$1 = {}; Object.defineProperty(gridRole$1, "__esModule", { value: true }); gridRole$1.default = void 0; var gridRole = { abstract: false, accessibleNameRequired: true, baseConcepts: [], childrenPresentational: false, nameFrom: ['author'], prohibitedProps: [], props: { 'aria-multiselectable': null, 'aria-readonly': null }, relatedConcepts: [{ concept: { attributes: [{ name: 'role', value: 'grid' }], name: 'table' }, module: 'HTML' }], requireContextRole: [], requiredContextRole: [], requiredOwnedElements: [['row'], ['row', 'rowgroup']], requiredProps: {}, superClass: [['roletype', 'widget', 'composite'], ['roletype', 'structure', 'section', 'table']] }; var _default$1A = gridRole; gridRole$1.default = _default$1A; var gridcellRole$1 = {}; Object.defineProperty(gridcellRole$1, "__esModule", { value: true }); gridcellRole$1.default = void 0; var gridcellRole = { abstract: false, accessibleNameRequired: false, baseConcepts: [], childrenPresentational: false, nameFrom: ['author', 'contents'], prohibitedProps: [], props: { 'aria-disabled': null, 'aria-errormessage': null, 'aria-expanded': null, 'aria-haspopup': null, 'aria-invalid': null, 'aria-readonly': null, 'aria-required': null, 'aria-selected': null }, relatedConcepts: [{ concept: { attributes: [{ name: 'role', value: 'gridcell' }], name: 'td' }, module: 'HTML' }], requireContextRole: ['row'], requiredContextRole: ['row'], requiredOwnedElements: [], requiredProps: {}, superClass: [['roletype', 'structure', 'section', 'cell'], ['roletype', 'widget']] }; var _default$1z = gridcellRole; gridcellRole$1.default = _default$1z; var groupRole$1 = {}; Object.defineProperty(groupRole$1, "__esModule", { value: true }); groupRole$1.default = void 0; var groupRole = { abstract: false, accessibleNameRequired: false, baseConcepts: [], childrenPresentational: false, nameFrom: ['author'], prohibitedProps: [], props: { 'aria-activedescendant': null, 'aria-disabled': null }, relatedConcepts: [{ concept: { name: 'details' }, module: 'HTML' }, { concept: { name: 'fieldset' }, module: 'HTML' }, { concept: { name: 'optgroup' }, module: 'HTML' }], requireContextRole: [], requiredContextRole: [], requiredOwnedElements: [], requiredProps: {}, superClass: [['roletype', 'structure', 'section']] }; var _default$1y = groupRole; groupRole$1.default = _default$1y; var headingRole$1 = {}; Object.defineProperty(headingRole$1, "__esModule", { value: true }); headingRole$1.default = void 0; var headingRole = { abstract: false, accessibleNameRequired: true, baseConcepts: [], childrenPresentational: false, nameFrom: ['author', 'contents'], prohibitedProps: [], props: { 'aria-level': '2' }, relatedConcepts: [{ concept: { name: 'h1' }, module: 'HTML' }, { concept: { name: 'h2' }, module: 'HTML' }, { concept: { name: 'h3' }, module: 'HTML' }, { concept: { name: 'h4' }, module: 'HTML' }, { concept: { name: 'h5' }, module: 'HTML' }, { concept: { name: 'h6' }, module: 'HTML' }], requireContextRole: [], requiredContextRole: [], requiredOwnedElements: [], requiredProps: { 'aria-level': '2' }, superClass: [['roletype', 'structure', 'sectionhead']] }; var _default$1x = headingRole; headingRole$1.default = _default$1x; var imgRole$1 = {}; Object.defineProperty(imgRole$1, "__esModule", { value: true }); imgRole$1.default = void 0; var imgRole = { abstract: false, accessibleNameRequired: true, baseConcepts: [], childrenPresentational: true, nameFrom: ['author'], prohibitedProps: [], props: {}, relatedConcepts: [{ concept: { attributes: [{ constraints: ['set'], name: 'alt' }], name: 'img' }, module: 'HTML' }, { concept: { attributes: [{ constraints: ['undefined'], name: 'alt' }], name: 'img' }, module: 'HTML' }, { concept: { name: 'imggroup' }, module: 'DTB' }], requireContextRole: [], requiredContextRole: [], requiredOwnedElements: [], requiredProps: {}, superClass: [['roletype', 'structure', 'section']] }; var _default$1w = imgRole; imgRole$1.default = _default$1w; var insertionRole$1 = {}; Object.defineProperty(insertionRole$1, "__esModule", { value: true }); insertionRole$1.default = void 0; var insertionRole = { abstract: false, accessibleNameRequired: false, baseConcepts: [], childrenPresentational: false, nameFrom: ['prohibited'], prohibitedProps: ['aria-label', 'aria-labelledby'], props: {}, relatedConcepts: [], requireContextRole: [], requiredContextRole: [], requiredOwnedElements: [], requiredProps: {}, superClass: [['roletype', 'structure', 'section']] }; var _default$1v = insertionRole; insertionRole$1.default = _default$1v; var linkRole$1 = {}; Object.defineProperty(linkRole$1, "__esModule", { value: true }); linkRole$1.default = void 0; var linkRole = { abstract: false, accessibleNameRequired: true, baseConcepts: [], childrenPresentational: false, nameFrom: ['author', 'contents'], prohibitedProps: [], props: { 'aria-disabled': null, 'aria-expanded': null, 'aria-haspopup': null }, relatedConcepts: [{ concept: { attributes: [{ name: 'href' }], name: 'a' }, module: 'HTML' }, { concept: { attributes: [{ name: 'href' }], name: 'area' }, module: 'HTML' }, { concept: { attributes: [{ name: 'href' }], name: 'link' }, module: 'HTML' }], requireContextRole: [], requiredContextRole: [], requiredOwnedElements: [], requiredProps: {}, superClass: [['roletype', 'widget', 'command']] }; var _default$1u = linkRole; linkRole$1.default = _default$1u; var listRole$1 = {}; Object.defineProperty(listRole$1, "__esModule", { value: true }); listRole$1.default = void 0; var listRole = { abstract: false, accessibleNameRequired: false, baseConcepts: [], childrenPresentational: false, nameFrom: ['author'], prohibitedProps: [], props: {}, relatedConcepts: [{ concept: { name: 'menu' }, module: 'HTML' }, { concept: { name: 'ol' }, module: 'HTML' }, { concept: { name: 'ul' }, module: 'HTML' }], requireContextRole: [], requiredContextRole: [], requiredOwnedElements: [['listitem']], requiredProps: {}, superClass: [['roletype', 'structure', 'section']] }; var _default$1t = listRole; listRole$1.default = _default$1t; var listboxRole$1 = {}; Object.defineProperty(listboxRole$1, "__esModule", { value: true }); listboxRole$1.default = void 0; var listboxRole = { abstract: false, accessibleNameRequired: true, baseConcepts: [], childrenPresentational: false, nameFrom: ['author'], prohibitedProps: [], props: { 'aria-errormessage': null, 'aria-expanded': null, 'aria-invalid': null, 'aria-multiselectable': null, 'aria-readonly': null, 'aria-required': null, 'aria-orientation': 'vertical' }, relatedConcepts: [{ concept: { attributes: [{ constraints: ['>1'], name: 'size' }, { name: 'multiple' }], name: 'select' }, module: 'HTML' }, { concept: { attributes: [{ constraints: ['>1'], name: 'size' }], name: 'select' }, module: 'HTML' }, { concept: { attributes: [{ name: 'multiple' }], name: 'select' }, module: 'HTML' }, { concept: { name: 'datalist' }, module: 'HTML' }, { concept: { name: 'list' }, module: 'ARIA' }, { concept: { name: 'select' }, module: 'XForms' }], requireContextRole: [], requiredContextRole: [], requiredOwnedElements: [['option', 'group'], ['option']], requiredProps: {}, superClass: [['roletype', 'widget', 'composite', 'select'], ['roletype', 'structure', 'section', 'group', 'select']] }; var _default$1s = listboxRole; listboxRole$1.default = _default$1s; var listitemRole$1 = {}; Object.defineProperty(listitemRole$1, "__esModule", { value: true }); listitemRole$1.default = void 0; var listitemRole = { abstract: false, accessibleNameRequired: false, baseConcepts: [], childrenPresentational: false, nameFrom: ['author'], prohibitedProps: [], props: { 'aria-level': null, 'aria-posinset': null, 'aria-setsize': null }, relatedConcepts: [{ concept: { constraints: ['direct descendant of ol, ul or menu'], name: 'li' }, module: 'HTML' }, { concept: { name: 'item' }, module: 'XForms' }], requireContextRole: ['directory', 'list'], requiredContextRole: ['directory', 'list'], requiredOwnedElements: [], requiredProps: {}, superClass: [['roletype', 'structure', 'section']] }; var _default$1r = listitemRole; listitemRole$1.default = _default$1r; var logRole$1 = {}; Object.defineProperty(logRole$1, "__esModule", { value: true }); logRole$1.default = void 0; var logRole = { abstract: false, accessibleNameRequired: false, baseConcepts: [], childrenPresentational: false, nameFrom: ['author'], prohibitedProps: [], props: { 'aria-live': 'polite' }, relatedConcepts: [], requireContextRole: [], requiredContextRole: [], requiredOwnedElements: [], requiredProps: {}, superClass: [['roletype', 'structure', 'section']] }; var _default$1q = logRole; logRole$1.default = _default$1q; var mainRole$1 = {}; Object.defineProperty(mainRole$1, "__esModule", { value: true }); mainRole$1.default = void 0; var mainRole = { abstract: false, accessibleNameRequired: false, baseConcepts: [], childrenPresentational: false, nameFrom: ['author'], prohibitedProps: [], props: {}, relatedConcepts: [{ concept: { name: 'main' }, module: 'HTML' }], requireContextRole: [], requiredContextRole: [], requiredOwnedElements: [], requiredProps: {}, superClass: [['roletype', 'structure', 'section', 'landmark']] }; var _default$1p = mainRole; mainRole$1.default = _default$1p; var marqueeRole$1 = {}; Object.defineProperty(marqueeRole$1, "__esModule", { value: true }); marqueeRole$1.default = void 0; var marqueeRole = { abstract: false, accessibleNameRequired: true, baseConcepts: [], childrenPresentational: false, nameFrom: ['author'], prohibitedProps: [], props: {}, relatedConcepts: [], requireContextRole: [], requiredContextRole: [], requiredOwnedElements: [], requiredProps: {}, superClass: [['roletype', 'structure', 'section']] }; var _default$1o = marqueeRole; marqueeRole$1.default = _default$1o; var mathRole$1 = {}; Object.defineProperty(mathRole$1, "__esModule", { value: true }); mathRole$1.default = void 0; var mathRole = { abstract: false, accessibleNameRequired: false, baseConcepts: [], childrenPresentational: false, nameFrom: ['author'], prohibitedProps: [], props: {}, relatedConcepts: [{ concept: { name: 'math' }, module: 'HTML' }], requireContextRole: [], requiredContextRole: [], requiredOwnedElements: [], requiredProps: {}, superClass: [['roletype', 'structure', 'section']] }; var _default$1n = mathRole; mathRole$1.default = _default$1n; var menuRole$1 = {}; Object.defineProperty(menuRole$1, "__esModule", { value: true }); menuRole$1.default = void 0; var menuRole = { abstract: false, accessibleNameRequired: false, baseConcepts: [], childrenPresentational: false, nameFrom: ['author'], prohibitedProps: [], props: { 'aria-orientation': 'vertical' }, relatedConcepts: [{ concept: { name: 'MENU' }, module: 'JAPI' }, { concept: { name: 'list' }, module: 'ARIA' }, { concept: { name: 'select' }, module: 'XForms' }, { concept: { name: 'sidebar' }, module: 'DTB' }], requireContextRole: [], requiredContextRole: [], requiredOwnedElements: [['menuitem', 'group'], ['menuitemradio', 'group'], ['menuitemcheckbox', 'group'], ['menuitem'], ['menuitemcheckbox'], ['menuitemradio']], requiredProps: {}, superClass: [['roletype', 'widget', 'composite', 'select'], ['roletype', 'structure', 'section', 'group', 'select']] }; var _default$1m = menuRole; menuRole$1.default = _default$1m; var menubarRole$1 = {}; Object.defineProperty(menubarRole$1, "__esModule", { value: true }); menubarRole$1.default = void 0; var menubarRole = { abstract: false, accessibleNameRequired: false, baseConcepts: [], childrenPresentational: false, nameFrom: ['author'], prohibitedProps: [], props: { 'aria-orientation': 'horizontal' }, relatedConcepts: [{ concept: { name: 'toolbar' }, module: 'ARIA' }], requireContextRole: [], requiredContextRole: [], requiredOwnedElements: [['menuitem', 'group'], ['menuitemradio', 'group'], ['menuitemcheckbox', 'group'], ['menuitem'], ['menuitemcheckbox'], ['menuitemradio']], requiredProps: {}, superClass: [['roletype', 'widget', 'composite', 'select', 'menu'], ['roletype', 'structure', 'section', 'group', 'select', 'menu']] }; var _default$1l = menubarRole; menubarRole$1.default = _default$1l; var menuitemRole$1 = {}; Object.defineProperty(menuitemRole$1, "__esModule", { value: true }); menuitemRole$1.default = void 0; var menuitemRole = { abstract: false, accessibleNameRequired: true, baseConcepts: [], childrenPresentational: false, nameFrom: ['author', 'contents'], prohibitedProps: [], props: { 'aria-disabled': null, 'aria-expanded': null, 'aria-haspopup': null, 'aria-posinset': null, 'aria-setsize': null }, relatedConcepts: [{ concept: { name: 'MENU_ITEM' }, module: 'JAPI' }, { concept: { name: 'listitem' }, module: 'ARIA' }, { concept: { name: 'menuitem' }, module: 'HTML' }, { concept: { name: 'option' }, module: 'ARIA' }], requireContextRole: ['group', 'menu', 'menubar'], requiredContextRole: ['group', 'menu', 'menubar'], requiredOwnedElements: [], requiredProps: {}, superClass: [['roletype', 'widget', 'command']] }; var _default$1k = menuitemRole; menuitemRole$1.default = _default$1k; var menuitemcheckboxRole$1 = {}; Object.defineProperty(menuitemcheckboxRole$1, "__esModule", { value: true }); menuitemcheckboxRole$1.default = void 0; var menuitemcheckboxRole = { abstract: false, accessibleNameRequired: true, baseConcepts: [], childrenPresentational: true, nameFrom: ['author', 'contents'], prohibitedProps: [], props: {}, relatedConcepts: [{ concept: { name: 'menuitem' }, module: 'ARIA' }], requireContextRole: ['group', 'menu', 'menubar'], requiredContextRole: ['group', 'menu', 'menubar'], requiredOwnedElements: [], requiredProps: { 'aria-checked': null }, superClass: [['roletype', 'widget', 'input', 'checkbox'], ['roletype', 'widget', 'command', 'menuitem']] }; var _default$1j = menuitemcheckboxRole; menuitemcheckboxRole$1.default = _default$1j; var menuitemradioRole$1 = {}; Object.defineProperty(menuitemradioRole$1, "__esModule", { value: true }); menuitemradioRole$1.default = void 0; var menuitemradioRole = { abstract: false, accessibleNameRequired: true, baseConcepts: [], childrenPresentational: true, nameFrom: ['author', 'contents'], prohibitedProps: [], props: {}, relatedConcepts: [{ concept: { name: 'menuitem' }, module: 'ARIA' }], requireContextRole: ['group', 'menu', 'menubar'], requiredContextRole: ['group', 'menu', 'menubar'], requiredOwnedElements: [], requiredProps: { 'aria-checked': null }, superClass: [['roletype', 'widget', 'input', 'checkbox', 'menuitemcheckbox'], ['roletype', 'widget', 'command', 'menuitem', 'menuitemcheckbox'], ['roletype', 'widget', 'input', 'radio']] }; var _default$1i = menuitemradioRole; menuitemradioRole$1.default = _default$1i; var meterRole$1 = {}; Object.defineProperty(meterRole$1, "__esModule", { value: true }); meterRole$1.default = void 0; var meterRole = { abstract: false, accessibleNameRequired: true, baseConcepts: [], childrenPresentational: true, nameFrom: ['author'], prohibitedProps: [], props: { 'aria-valuetext': null, 'aria-valuemax': '100', 'aria-valuemin': '0' }, relatedConcepts: [], requireContextRole: [], requiredContextRole: [], requiredOwnedElements: [], requiredProps: { 'aria-valuenow': null }, superClass: [['roletype', 'structure', 'range']] }; var _default$1h = meterRole; meterRole$1.default = _default$1h; var navigationRole$1 = {}; Object.defineProperty(navigationRole$1, "__esModule", { value: true }); navigationRole$1.default = void 0; var navigationRole = { abstract: false, accessibleNameRequired: false, baseConcepts: [], childrenPresentational: false, nameFrom: ['author'], prohibitedProps: [], props: {}, relatedConcepts: [{ concept: { name: 'nav' }, module: 'HTML' }], requireContextRole: [], requiredContextRole: [], requiredOwnedElements: [], requiredProps: {}, superClass: [['roletype', 'structure', 'section', 'landmark']] }; var _default$1g = navigationRole; navigationRole$1.default = _default$1g; var noneRole$1 = {}; Object.defineProperty(noneRole$1, "__esModule", { value: true }); noneRole$1.default = void 0; var noneRole = { abstract: false, accessibleNameRequired: false, baseConcepts: [], childrenPresentational: false, nameFrom: [], prohibitedProps: [], props: {}, relatedConcepts: [], requireContextRole: [], requiredContextRole: [], requiredOwnedElements: [], requiredProps: {}, superClass: [] }; var _default$1f = noneRole; noneRole$1.default = _default$1f; var noteRole$1 = {}; Object.defineProperty(noteRole$1, "__esModule", { value: true }); noteRole$1.default = void 0; var noteRole = { abstract: false, accessibleNameRequired: false, baseConcepts: [], childrenPresentational: false, nameFrom: ['author'], prohibitedProps: [], props: {}, relatedConcepts: [], requireContextRole: [], requiredContextRole: [], requiredOwnedElements: [], requiredProps: {}, superClass: [['roletype', 'structure', 'section']] }; var _default$1e = noteRole; noteRole$1.default = _default$1e; var optionRole$1 = {}; Object.defineProperty(optionRole$1, "__esModule", { value: true }); optionRole$1.default = void 0; var optionRole = { abstract: false, accessibleNameRequired: true, baseConcepts: [], childrenPresentational: true, nameFrom: ['author', 'contents'], prohibitedProps: [], props: { 'aria-checked': null, 'aria-posinset': null, 'aria-setsize': null, 'aria-selected': 'false' }, relatedConcepts: [{ concept: { name: 'item' }, module: 'XForms' }, { concept: { name: 'listitem' }, module: 'ARIA' }, { concept: { name: 'option' }, module: 'HTML' }], requireContextRole: [], requiredContextRole: [], requiredOwnedElements: [], requiredProps: { 'aria-selected': 'false' }, superClass: [['roletype', 'widget', 'input']] }; var _default$1d = optionRole; optionRole$1.default = _default$1d; var paragraphRole$1 = {}; Object.defineProperty(paragraphRole$1, "__esModule", { value: true }); paragraphRole$1.default = void 0; var paragraphRole = { abstract: false, accessibleNameRequired: false, baseConcepts: [], childrenPresentational: false, nameFrom: ['prohibited'], prohibitedProps: ['aria-label', 'aria-labelledby'], props: {}, relatedConcepts: [], requireContextRole: [], requiredContextRole: [], requiredOwnedElements: [], requiredProps: {}, superClass: [['roletype', 'structure', 'section']] }; var _default$1c = paragraphRole; paragraphRole$1.default = _default$1c; var presentationRole$1 = {}; Object.defineProperty(presentationRole$1, "__esModule", { value: true }); presentationRole$1.default = void 0; var presentationRole = { abstract: false, accessibleNameRequired: false, baseConcepts: [], childrenPresentational: false, nameFrom: ['prohibited'], prohibitedProps: ['aria-label', 'aria-labelledby'], props: {}, relatedConcepts: [], requireContextRole: [], requiredContextRole: [], requiredOwnedElements: [], requiredProps: {}, superClass: [['roletype', 'structure']] }; var _default$1b = presentationRole; presentationRole$1.default = _default$1b; var progressbarRole$1 = {}; Object.defineProperty(progressbarRole$1, "__esModule", { value: true }); progressbarRole$1.default = void 0; var progressbarRole = { abstract: false, accessibleNameRequired: true, baseConcepts: [], childrenPresentational: true, nameFrom: ['author'], prohibitedProps: [], props: { 'aria-valuetext': null }, relatedConcepts: [{ concept: { name: 'progress' }, module: 'HTML' }, { concept: { name: 'status' }, module: 'ARIA' }], requireContextRole: [], requiredContextRole: [], requiredOwnedElements: [], requiredProps: {}, superClass: [['roletype', 'structure', 'range'], ['roletype', 'widget']] }; var _default$1a = progressbarRole; progressbarRole$1.default = _default$1a; var radioRole$1 = {}; Object.defineProperty(radioRole$1, "__esModule", { value: true }); radioRole$1.default = void 0; var radioRole = { abstract: false, accessibleNameRequired: true, baseConcepts: [], childrenPresentational: true, nameFrom: ['author', 'contents'], prohibitedProps: [], props: { 'aria-checked': null, 'aria-posinset': null, 'aria-setsize': null }, relatedConcepts: [{ concept: { attributes: [{ name: 'type', value: 'radio' }], name: 'input' }, module: 'HTML' }], requireContextRole: [], requiredContextRole: [], requiredOwnedElements: [], requiredProps: { 'aria-checked': null }, superClass: [['roletype', 'widget', 'input']] }; var _default$19 = radioRole; radioRole$1.default = _default$19; var radiogroupRole$1 = {}; Object.defineProperty(radiogroupRole$1, "__esModule", { value: true }); radiogroupRole$1.default = void 0; var radiogroupRole = { abstract: false, accessibleNameRequired: true, baseConcepts: [], childrenPresentational: false, nameFrom: ['author'], prohibitedProps: [], props: { 'aria-errormessage': null, 'aria-invalid': null, 'aria-readonly': null, 'aria-required': null }, relatedConcepts: [{ concept: { name: 'list' }, module: 'ARIA' }], requireContextRole: [], requiredContextRole: [], requiredOwnedElements: [['radio']], requiredProps: {}, superClass: [['roletype', 'widget', 'composite', 'select'], ['roletype', 'structure', 'section', 'group', 'select']] }; var _default$18 = radiogroupRole; radiogroupRole$1.default = _default$18; var regionRole$1 = {}; Object.defineProperty(regionRole$1, "__esModule", { value: true }); regionRole$1.default = void 0; var regionRole = { abstract: false, accessibleNameRequired: true, baseConcepts: [], childrenPresentational: false, nameFrom: ['author'], prohibitedProps: [], props: {}, relatedConcepts: [{ concept: { attributes: [{ constraints: ['set'], name: 'aria-label' }], name: 'section' }, module: 'HTML' }, { concept: { attributes: [{ constraints: ['set'], name: 'aria-labelledby' }], name: 'section' }, module: 'HTML' }, { concept: { name: 'Device Independence Glossart perceivable unit' } }, { concept: { name: 'frame' }, module: 'HTML' }], requireContextRole: [], requiredContextRole: [], requiredOwnedElements: [], requiredProps: {}, superClass: [['roletype', 'structure', 'section', 'landmark']] }; var _default$17 = regionRole; regionRole$1.default = _default$17; var rowRole$1 = {}; Object.defineProperty(rowRole$1, "__esModule", { value: true }); rowRole$1.default = void 0; var rowRole = { abstract: false, accessibleNameRequired: false, baseConcepts: [], childrenPresentational: false, nameFrom: ['author', 'contents'], prohibitedProps: [], props: { 'aria-colindex': null, 'aria-expanded': null, 'aria-level': null, 'aria-posinset': null, 'aria-rowindex': null, 'aria-selected': null, 'aria-setsize': null }, relatedConcepts: [{ concept: { name: 'tr' }, module: 'HTML' }], requireContextRole: ['grid', 'rowgroup', 'table', 'treegrid'], requiredContextRole: ['grid', 'rowgroup', 'table', 'treegrid'], requiredOwnedElements: [['cell'], ['columnheader'], ['gridcell'], ['rowheader']], requiredProps: {}, superClass: [['roletype', 'structure', 'section', 'group'], ['roletype', 'widget']] }; var _default$16 = rowRole; rowRole$1.default = _default$16; var rowgroupRole$1 = {}; Object.defineProperty(rowgroupRole$1, "__esModule", { value: true }); rowgroupRole$1.default = void 0; var rowgroupRole = { abstract: false, accessibleNameRequired: false, baseConcepts: [], childrenPresentational: false, nameFrom: ['author', 'contents'], prohibitedProps: [], props: {}, relatedConcepts: [{ concept: { name: 'tbody' }, module: 'HTML' }, { concept: { name: 'tfoot' }, module: 'HTML' }, { concept: { name: 'thead' }, module: 'HTML' }], requireContextRole: ['grid', 'table', 'treegrid'], requiredContextRole: ['grid', 'table', 'treegrid'], requiredOwnedElements: [['row']], requiredProps: {}, superClass: [['roletype', 'structure']] }; var _default$15 = rowgroupRole; rowgroupRole$1.default = _default$15; var rowheaderRole$1 = {}; Object.defineProperty(rowheaderRole$1, "__esModule", { value: true }); rowheaderRole$1.default = void 0; var rowheaderRole = { abstract: false, accessibleNameRequired: true, baseConcepts: [], childrenPresentational: false, nameFrom: ['author', 'contents'], prohibitedProps: [], props: { 'aria-sort': null }, relatedConcepts: [{ concept: { attributes: [{ name: 'scope', value: 'row' }], name: 'th' }, module: 'HTML' }], requireContextRole: ['row'], requiredContextRole: ['row'], requiredOwnedElements: [], requiredProps: {}, superClass: [['roletype', 'structure', 'section', 'cell'], ['roletype', 'structure', 'section', 'cell', 'gridcell'], ['roletype', 'widget', 'gridcell'], ['roletype', 'structure', 'sectionhead']] }; var _default$14 = rowheaderRole; rowheaderRole$1.default = _default$14; var scrollbarRole$1 = {}; Object.defineProperty(scrollbarRole$1, "__esModule", { value: true }); scrollbarRole$1.default = void 0; var scrollbarRole = { abstract: false, accessibleNameRequired: false, baseConcepts: [], childrenPresentational: true, nameFrom: ['author'], prohibitedProps: [], props: { 'aria-disabled': null, 'aria-valuetext': null, 'aria-orientation': 'vertical', 'aria-valuemax': '100', 'aria-valuemin': '0' }, relatedConcepts: [], requireContextRole: [], requiredContextRole: [], requiredOwnedElements: [], requiredProps: { 'aria-controls': null, 'aria-valuenow': null }, superClass: [['roletype', 'structure', 'range'], ['roletype', 'widget']] }; var _default$13 = scrollbarRole; scrollbarRole$1.default = _default$13; var searchRole$1 = {}; Object.defineProperty(searchRole$1, "__esModule", { value: true }); searchRole$1.default = void 0; var searchRole = { abstract: false, accessibleNameRequired: false, baseConcepts: [], childrenPresentational: false, nameFrom: ['author'], prohibitedProps: [], props: {}, relatedConcepts: [], requireContextRole: [], requiredContextRole: [], requiredOwnedElements: [], requiredProps: {}, superClass: [['roletype', 'structure', 'section', 'landmark']] }; var _default$12 = searchRole; searchRole$1.default = _default$12; var searchboxRole$1 = {}; Object.defineProperty(searchboxRole$1, "__esModule", { value: true }); searchboxRole$1.default = void 0; var searchboxRole = { abstract: false, accessibleNameRequired: true, baseConcepts: [], childrenPresentational: false, nameFrom: ['author'], prohibitedProps: [], props: {}, relatedConcepts: [{ concept: { attributes: [{ constraints: ['undefined'], name: 'list' }, { name: 'type', value: 'search' }], name: 'input' }, module: 'HTML' }], requireContextRole: [], requiredContextRole: [], requiredOwnedElements: [], requiredProps: {}, superClass: [['roletype', 'widget', 'input', 'textbox']] }; var _default$11 = searchboxRole; searchboxRole$1.default = _default$11; var separatorRole$1 = {}; Object.defineProperty(separatorRole$1, "__esModule", { value: true }); separatorRole$1.default = void 0; var separatorRole = { abstract: false, accessibleNameRequired: false, baseConcepts: [], childrenPresentational: true, nameFrom: ['author'], prohibitedProps: [], props: { 'aria-disabled': null, 'aria-orientation': 'horizontal', 'aria-valuemax': '100', 'aria-valuemin': '0', 'aria-valuenow': null, 'aria-valuetext': null }, relatedConcepts: [{ concept: { name: 'hr' }, module: 'HTML' }], requireContextRole: [], requiredContextRole: [], requiredOwnedElements: [], requiredProps: {}, superClass: [['roletype', 'structure']] }; var _default$10 = separatorRole; separatorRole$1.default = _default$10; var sliderRole$1 = {}; Object.defineProperty(sliderRole$1, "__esModule", { value: true }); sliderRole$1.default = void 0; var sliderRole = { abstract: false, accessibleNameRequired: true, baseConcepts: [], childrenPresentational: true, nameFrom: ['author'], prohibitedProps: [], props: { 'aria-errormessage': null, 'aria-haspopup': null, 'aria-invalid': null, 'aria-readonly': null, 'aria-valuetext': null, 'aria-orientation': 'horizontal', 'aria-valuemax': '100', 'aria-valuemin': '0' }, relatedConcepts: [{ concept: { attributes: [{ name: 'type', value: 'range' }], name: 'input' }, module: 'HTML' }], requireContextRole: [], requiredContextRole: [], requiredOwnedElements: [], requiredProps: { 'aria-valuenow': null }, superClass: [['roletype', 'widget', 'input'], ['roletype', 'structure', 'range']] }; var _default$$ = sliderRole; sliderRole$1.default = _default$$; var spinbuttonRole$1 = {}; Object.defineProperty(spinbuttonRole$1, "__esModule", { value: true }); spinbuttonRole$1.default = void 0; var spinbuttonRole = { abstract: false, accessibleNameRequired: true, baseConcepts: [], childrenPresentational: false, nameFrom: ['author'], prohibitedProps: [], props: { 'aria-errormessage': null, 'aria-invalid': null, 'aria-readonly': null, 'aria-required': null, 'aria-valuetext': null, 'aria-valuenow': '0' }, relatedConcepts: [{ concept: { attributes: [{ name: 'type', value: 'number' }], name: 'input' }, module: 'HTML' }], requireContextRole: [], requiredContextRole: [], requiredOwnedElements: [], requiredProps: {}, superClass: [['roletype', 'widget', 'composite'], ['roletype', 'widget', 'input'], ['roletype', 'structure', 'range']] }; var _default$_ = spinbuttonRole; spinbuttonRole$1.default = _default$_; var statusRole$1 = {}; Object.defineProperty(statusRole$1, "__esModule", { value: true }); statusRole$1.default = void 0; var statusRole = { abstract: false, accessibleNameRequired: false, baseConcepts: [], childrenPresentational: false, nameFrom: ['author'], prohibitedProps: [], props: { 'aria-atomic': 'true', 'aria-live': 'polite' }, relatedConcepts: [{ concept: { name: 'output' }, module: 'HTML' }], requireContextRole: [], requiredContextRole: [], requiredOwnedElements: [], requiredProps: {}, superClass: [['roletype', 'structure', 'section']] }; var _default$Z = statusRole; statusRole$1.default = _default$Z; var strongRole$1 = {}; Object.defineProperty(strongRole$1, "__esModule", { value: true }); strongRole$1.default = void 0; var strongRole = { abstract: false, accessibleNameRequired: false, baseConcepts: [], childrenPresentational: false, nameFrom: ['prohibited'], prohibitedProps: ['aria-label', 'aria-labelledby'], props: {}, relatedConcepts: [], requireContextRole: [], requiredContextRole: [], requiredOwnedElements: [], requiredProps: {}, superClass: [['roletype', 'structure', 'section']] }; var _default$Y = strongRole; strongRole$1.default = _default$Y; var subscriptRole$1 = {}; Object.defineProperty(subscriptRole$1, "__esModule", { value: true }); subscriptRole$1.default = void 0; var subscriptRole = { abstract: false, accessibleNameRequired: false, baseConcepts: [], childrenPresentational: false, nameFrom: ['prohibited'], prohibitedProps: ['aria-label', 'aria-labelledby'], props: {}, relatedConcepts: [], requireContextRole: [], requiredContextRole: [], requiredOwnedElements: [], requiredProps: {}, superClass: [['roletype', 'structure', 'section']] }; var _default$X = subscriptRole; subscriptRole$1.default = _default$X; var superscriptRole$1 = {}; Object.defineProperty(superscriptRole$1, "__esModule", { value: true }); superscriptRole$1.default = void 0; var superscriptRole = { abstract: false, accessibleNameRequired: false, baseConcepts: [], childrenPresentational: false, nameFrom: ['prohibited'], prohibitedProps: ['aria-label', 'aria-labelledby'], props: {}, relatedConcepts: [], requireContextRole: [], requiredContextRole: [], requiredOwnedElements: [], requiredProps: {}, superClass: [['roletype', 'structure', 'section']] }; var _default$W = superscriptRole; superscriptRole$1.default = _default$W; var switchRole$1 = {}; Object.defineProperty(switchRole$1, "__esModule", { value: true }); switchRole$1.default = void 0; var switchRole = { abstract: false, accessibleNameRequired: true, baseConcepts: [], childrenPresentational: true, nameFrom: ['author', 'contents'], prohibitedProps: [], props: {}, relatedConcepts: [{ concept: { name: 'button' }, module: 'ARIA' }], requireContextRole: [], requiredContextRole: [], requiredOwnedElements: [], requiredProps: { 'aria-checked': null }, superClass: [['roletype', 'widget', 'input', 'checkbox']] }; var _default$V = switchRole; switchRole$1.default = _default$V; var tabRole$1 = {}; Object.defineProperty(tabRole$1, "__esModule", { value: true }); tabRole$1.default = void 0; var tabRole = { abstract: false, accessibleNameRequired: false, baseConcepts: [], childrenPresentational: true, nameFrom: ['author', 'contents'], prohibitedProps: [], props: { 'aria-disabled': null, 'aria-expanded': null, 'aria-haspopup': null, 'aria-posinset': null, 'aria-setsize': null, 'aria-selected': 'false' }, relatedConcepts: [], requireContextRole: ['tablist'], requiredContextRole: ['tablist'], requiredOwnedElements: [], requiredProps: {}, superClass: [['roletype', 'structure', 'sectionhead'], ['roletype', 'widget']] }; var _default$U = tabRole; tabRole$1.default = _default$U; var tableRole$1 = {}; Object.defineProperty(tableRole$1, "__esModule", { value: true }); tableRole$1.default = void 0; var tableRole = { abstract: false, accessibleNameRequired: true, baseConcepts: [], childrenPresentational: false, nameFrom: ['author'], prohibitedProps: [], props: { 'aria-colcount': null, 'aria-rowcount': null }, relatedConcepts: [{ concept: { name: 'table' }, module: 'HTML' }], requireContextRole: [], requiredContextRole: [], requiredOwnedElements: [['row'], ['row', 'rowgroup']], requiredProps: {}, superClass: [['roletype', 'structure', 'section']] }; var _default$T = tableRole; tableRole$1.default = _default$T; var tablistRole$1 = {}; Object.defineProperty(tablistRole$1, "__esModule", { value: true }); tablistRole$1.default = void 0; var tablistRole = { abstract: false, accessibleNameRequired: false, baseConcepts: [], childrenPresentational: false, nameFrom: ['author'], prohibitedProps: [], props: { 'aria-level': null, 'aria-multiselectable': null, 'aria-orientation': 'horizontal' }, relatedConcepts: [{ module: 'DAISY', concept: { name: 'guide' } }], requireContextRole: [], requiredContextRole: [], requiredOwnedElements: [['tab']], requiredProps: {}, superClass: [['roletype', 'widget', 'composite']] }; var _default$S = tablistRole; tablistRole$1.default = _default$S; var tabpanelRole$1 = {}; Object.defineProperty(tabpanelRole$1, "__esModule", { value: true }); tabpanelRole$1.default = void 0; var tabpanelRole = { abstract: false, accessibleNameRequired: true, baseConcepts: [], childrenPresentational: false, nameFrom: ['author'], prohibitedProps: [], props: {}, relatedConcepts: [], requireContextRole: [], requiredContextRole: [], requiredOwnedElements: [], requiredProps: {}, superClass: [['roletype', 'structure', 'section']] }; var _default$R = tabpanelRole; tabpanelRole$1.default = _default$R; var termRole$1 = {}; Object.defineProperty(termRole$1, "__esModule", { value: true }); termRole$1.default = void 0; var termRole = { abstract: false, accessibleNameRequired: false, baseConcepts: [], childrenPresentational: false, nameFrom: ['author'], prohibitedProps: [], props: {}, relatedConcepts: [{ concept: { name: 'dfn' }, module: 'HTML' }, { concept: { name: 'dt' }, module: 'HTML' }], requireContextRole: [], requiredContextRole: [], requiredOwnedElements: [], requiredProps: {}, superClass: [['roletype', 'structure', 'section']] }; var _default$Q = termRole; termRole$1.default = _default$Q; var textboxRole$1 = {}; Object.defineProperty(textboxRole$1, "__esModule", { value: true }); textboxRole$1.default = void 0; var textboxRole = { abstract: false, accessibleNameRequired: true, baseConcepts: [], childrenPresentational: false, nameFrom: ['author'], prohibitedProps: [], props: { 'aria-activedescendant': null, 'aria-autocomplete': null, 'aria-errormessage': null, 'aria-haspopup': null, 'aria-invalid': null, 'aria-multiline': null, 'aria-placeholder': null, 'aria-readonly': null, 'aria-required': null }, relatedConcepts: [{ concept: { attributes: [{ constraints: ['undefined'], name: 'type' }, { constraints: ['undefined'], name: 'list' }], name: 'input' }, module: 'HTML' }, { concept: { attributes: [{ constraints: ['undefined'], name: 'list' }, { name: 'type', value: 'email' }], name: 'input' }, module: 'HTML' }, { concept: { attributes: [{ constraints: ['undefined'], name: 'list' }, { name: 'type', value: 'tel' }], name: 'input' }, module: 'HTML' }, { concept: { attributes: [{ constraints: ['undefined'], name: 'list' }, { name: 'type', value: 'text' }], name: 'input' }, module: 'HTML' }, { concept: { attributes: [{ constraints: ['undefined'], name: 'list' }, { name: 'type', value: 'url' }], name: 'input' }, module: 'HTML' }, { concept: { name: 'input' }, module: 'XForms' }, { concept: { name: 'textarea' }, module: 'HTML' }], requireContextRole: [], requiredContextRole: [], requiredOwnedElements: [], requiredProps: {}, superClass: [['roletype', 'widget', 'input']] }; var _default$P = textboxRole; textboxRole$1.default = _default$P; var timeRole$1 = {}; Object.defineProperty(timeRole$1, "__esModule", { value: true }); timeRole$1.default = void 0; var timeRole = { abstract: false, accessibleNameRequired: false, baseConcepts: [], childrenPresentational: false, nameFrom: ['author'], prohibitedProps: [], props: {}, relatedConcepts: [], requireContextRole: [], requiredContextRole: [], requiredOwnedElements: [], requiredProps: {}, superClass: [['roletype', 'structure', 'section']] }; var _default$O = timeRole; timeRole$1.default = _default$O; var timerRole$1 = {}; Object.defineProperty(timerRole$1, "__esModule", { value: true }); timerRole$1.default = void 0; var timerRole = { abstract: false, accessibleNameRequired: false, baseConcepts: [], childrenPresentational: false, nameFrom: ['author'], prohibitedProps: [], props: {}, relatedConcepts: [], requireContextRole: [], requiredContextRole: [], requiredOwnedElements: [], requiredProps: {}, superClass: [['roletype', 'structure', 'section', 'status']] }; var _default$N = timerRole; timerRole$1.default = _default$N; var toolbarRole$1 = {}; Object.defineProperty(toolbarRole$1, "__esModule", { value: true }); toolbarRole$1.default = void 0; var toolbarRole = { abstract: false, accessibleNameRequired: false, baseConcepts: [], childrenPresentational: false, nameFrom: ['author'], prohibitedProps: [], props: { 'aria-orientation': 'horizontal' }, relatedConcepts: [{ concept: { name: 'menubar' }, module: 'ARIA' }], requireContextRole: [], requiredContextRole: [], requiredOwnedElements: [], requiredProps: {}, superClass: [['roletype', 'structure', 'section', 'group']] }; var _default$M = toolbarRole; toolbarRole$1.default = _default$M; var tooltipRole$1 = {}; Object.defineProperty(tooltipRole$1, "__esModule", { value: true }); tooltipRole$1.default = void 0; var tooltipRole = { abstract: false, accessibleNameRequired: true, baseConcepts: [], childrenPresentational: false, nameFrom: ['author', 'contents'], prohibitedProps: [], props: {}, relatedConcepts: [], requireContextRole: [], requiredContextRole: [], requiredOwnedElements: [], requiredProps: {}, superClass: [['roletype', 'structure', 'section']] }; var _default$L = tooltipRole; tooltipRole$1.default = _default$L; var treeRole$1 = {}; Object.defineProperty(treeRole$1, "__esModule", { value: true }); treeRole$1.default = void 0; var treeRole = { abstract: false, accessibleNameRequired: true, baseConcepts: [], childrenPresentational: false, nameFrom: ['author'], prohibitedProps: [], props: { 'aria-errormessage': null, 'aria-invalid': null, 'aria-multiselectable': null, 'aria-required': null, 'aria-orientation': 'vertical' }, relatedConcepts: [], requireContextRole: [], requiredContextRole: [], requiredOwnedElements: [['treeitem', 'group'], ['treeitem']], requiredProps: {}, superClass: [['roletype', 'widget', 'composite', 'select'], ['roletype', 'structure', 'section', 'group', 'select']] }; var _default$K = treeRole; treeRole$1.default = _default$K; var treegridRole$1 = {}; Object.defineProperty(treegridRole$1, "__esModule", { value: true }); treegridRole$1.default = void 0; var treegridRole = { abstract: false, accessibleNameRequired: true, baseConcepts: [], childrenPresentational: false, nameFrom: ['author'], prohibitedProps: [], props: {}, relatedConcepts: [], requireContextRole: [], requiredContextRole: [], requiredOwnedElements: [['row'], ['row', 'rowgroup']], requiredProps: {}, superClass: [['roletype', 'widget', 'composite', 'grid'], ['roletype', 'structure', 'section', 'table', 'grid'], ['roletype', 'widget', 'composite', 'select', 'tree'], ['roletype', 'structure', 'section', 'group', 'select', 'tree']] }; var _default$J = treegridRole; treegridRole$1.default = _default$J; var treeitemRole$1 = {}; Object.defineProperty(treeitemRole$1, "__esModule", { value: true }); treeitemRole$1.default = void 0; var treeitemRole = { abstract: false, accessibleNameRequired: true, baseConcepts: [], childrenPresentational: false, nameFrom: ['author', 'contents'], prohibitedProps: [], props: { 'aria-expanded': null, 'aria-haspopup': null }, relatedConcepts: [], requireContextRole: ['group', 'tree'], requiredContextRole: ['group', 'tree'], requiredOwnedElements: [], requiredProps: { 'aria-selected': null }, superClass: [['roletype', 'structure', 'section', 'listitem'], ['roletype', 'widget', 'input', 'option']] }; var _default$I = treeitemRole; treeitemRole$1.default = _default$I; Object.defineProperty(ariaLiteralRoles$1, "__esModule", { value: true }); ariaLiteralRoles$1.default = void 0; var _alertRole = _interopRequireDefault$5(alertRole$1); var _alertdialogRole = _interopRequireDefault$5(alertdialogRole$1); var _applicationRole = _interopRequireDefault$5(applicationRole$1); var _articleRole = _interopRequireDefault$5(articleRole$1); var _bannerRole = _interopRequireDefault$5(bannerRole$1); var _blockquoteRole = _interopRequireDefault$5(blockquoteRole$1); var _buttonRole = _interopRequireDefault$5(buttonRole$1); var _captionRole = _interopRequireDefault$5(captionRole$1); var _cellRole = _interopRequireDefault$5(cellRole$1); var _checkboxRole = _interopRequireDefault$5(checkboxRole$1); var _codeRole = _interopRequireDefault$5(codeRole$1); var _columnheaderRole = _interopRequireDefault$5(columnheaderRole$1); var _comboboxRole = _interopRequireDefault$5(comboboxRole$1); var _complementaryRole = _interopRequireDefault$5(complementaryRole$1); var _contentinfoRole = _interopRequireDefault$5(contentinfoRole$1); var _definitionRole = _interopRequireDefault$5(definitionRole$1); var _deletionRole = _interopRequireDefault$5(deletionRole$1); var _dialogRole = _interopRequireDefault$5(dialogRole$1); var _directoryRole = _interopRequireDefault$5(directoryRole$1); var _documentRole = _interopRequireDefault$5(documentRole$1); var _emphasisRole = _interopRequireDefault$5(emphasisRole$1); var _feedRole = _interopRequireDefault$5(feedRole$1); var _figureRole = _interopRequireDefault$5(figureRole$1); var _formRole = _interopRequireDefault$5(formRole$1); var _genericRole = _interopRequireDefault$5(genericRole$1); var _gridRole = _interopRequireDefault$5(gridRole$1); var _gridcellRole = _interopRequireDefault$5(gridcellRole$1); var _groupRole = _interopRequireDefault$5(groupRole$1); var _headingRole = _interopRequireDefault$5(headingRole$1); var _imgRole = _interopRequireDefault$5(imgRole$1); var _insertionRole = _interopRequireDefault$5(insertionRole$1); var _linkRole = _interopRequireDefault$5(linkRole$1); var _listRole = _interopRequireDefault$5(listRole$1); var _listboxRole = _interopRequireDefault$5(listboxRole$1); var _listitemRole = _interopRequireDefault$5(listitemRole$1); var _logRole = _interopRequireDefault$5(logRole$1); var _mainRole = _interopRequireDefault$5(mainRole$1); var _marqueeRole = _interopRequireDefault$5(marqueeRole$1); var _mathRole = _interopRequireDefault$5(mathRole$1); var _menuRole = _interopRequireDefault$5(menuRole$1); var _menubarRole = _interopRequireDefault$5(menubarRole$1); var _menuitemRole = _interopRequireDefault$5(menuitemRole$1); var _menuitemcheckboxRole = _interopRequireDefault$5(menuitemcheckboxRole$1); var _menuitemradioRole = _interopRequireDefault$5(menuitemradioRole$1); var _meterRole = _interopRequireDefault$5(meterRole$1); var _navigationRole = _interopRequireDefault$5(navigationRole$1); var _noneRole = _interopRequireDefault$5(noneRole$1); var _noteRole = _interopRequireDefault$5(noteRole$1); var _optionRole = _interopRequireDefault$5(optionRole$1); var _paragraphRole = _interopRequireDefault$5(paragraphRole$1); var _presentationRole = _interopRequireDefault$5(presentationRole$1); var _progressbarRole = _interopRequireDefault$5(progressbarRole$1); var _radioRole = _interopRequireDefault$5(radioRole$1); var _radiogroupRole = _interopRequireDefault$5(radiogroupRole$1); var _regionRole = _interopRequireDefault$5(regionRole$1); var _rowRole = _interopRequireDefault$5(rowRole$1); var _rowgroupRole = _interopRequireDefault$5(rowgroupRole$1); var _rowheaderRole = _interopRequireDefault$5(rowheaderRole$1); var _scrollbarRole = _interopRequireDefault$5(scrollbarRole$1); var _searchRole = _interopRequireDefault$5(searchRole$1); var _searchboxRole = _interopRequireDefault$5(searchboxRole$1); var _separatorRole = _interopRequireDefault$5(separatorRole$1); var _sliderRole = _interopRequireDefault$5(sliderRole$1); var _spinbuttonRole = _interopRequireDefault$5(spinbuttonRole$1); var _statusRole = _interopRequireDefault$5(statusRole$1); var _strongRole = _interopRequireDefault$5(strongRole$1); var _subscriptRole = _interopRequireDefault$5(subscriptRole$1); var _superscriptRole = _interopRequireDefault$5(superscriptRole$1); var _switchRole = _interopRequireDefault$5(switchRole$1); var _tabRole = _interopRequireDefault$5(tabRole$1); var _tableRole = _interopRequireDefault$5(tableRole$1); var _tablistRole = _interopRequireDefault$5(tablistRole$1); var _tabpanelRole = _interopRequireDefault$5(tabpanelRole$1); var _termRole = _interopRequireDefault$5(termRole$1); var _textboxRole = _interopRequireDefault$5(textboxRole$1); var _timeRole = _interopRequireDefault$5(timeRole$1); var _timerRole = _interopRequireDefault$5(timerRole$1); var _toolbarRole = _interopRequireDefault$5(toolbarRole$1); var _tooltipRole = _interopRequireDefault$5(tooltipRole$1); var _treeRole = _interopRequireDefault$5(treeRole$1); var _treegridRole = _interopRequireDefault$5(treegridRole$1); var _treeitemRole = _interopRequireDefault$5(treeitemRole$1); function _interopRequireDefault$5(obj) { return obj && obj.__esModule ? obj : { default: obj }; } var ariaLiteralRoles = [['alert', _alertRole.default], ['alertdialog', _alertdialogRole.default], ['application', _applicationRole.default], ['article', _articleRole.default], ['banner', _bannerRole.default], ['blockquote', _blockquoteRole.default], ['button', _buttonRole.default], ['caption', _captionRole.default], ['cell', _cellRole.default], ['checkbox', _checkboxRole.default], ['code', _codeRole.default], ['columnheader', _columnheaderRole.default], ['combobox', _comboboxRole.default], ['complementary', _complementaryRole.default], ['contentinfo', _contentinfoRole.default], ['definition', _definitionRole.default], ['deletion', _deletionRole.default], ['dialog', _dialogRole.default], ['directory', _directoryRole.default], ['document', _documentRole.default], ['emphasis', _emphasisRole.default], ['feed', _feedRole.default], ['figure', _figureRole.default], ['form', _formRole.default], ['generic', _genericRole.default], ['grid', _gridRole.default], ['gridcell', _gridcellRole.default], ['group', _groupRole.default], ['heading', _headingRole.default], ['img', _imgRole.default], ['insertion', _insertionRole.default], ['link', _linkRole.default], ['list', _listRole.default], ['listbox', _listboxRole.default], ['listitem', _listitemRole.default], ['log', _logRole.default], ['main', _mainRole.default], ['marquee', _marqueeRole.default], ['math', _mathRole.default], ['menu', _menuRole.default], ['menubar', _menubarRole.default], ['menuitem', _menuitemRole.default], ['menuitemcheckbox', _menuitemcheckboxRole.default], ['menuitemradio', _menuitemradioRole.default], ['meter', _meterRole.default], ['navigation', _navigationRole.default], ['none', _noneRole.default], ['note', _noteRole.default], ['option', _optionRole.default], ['paragraph', _paragraphRole.default], ['presentation', _presentationRole.default], ['progressbar', _progressbarRole.default], ['radio', _radioRole.default], ['radiogroup', _radiogroupRole.default], ['region', _regionRole.default], ['row', _rowRole.default], ['rowgroup', _rowgroupRole.default], ['rowheader', _rowheaderRole.default], ['scrollbar', _scrollbarRole.default], ['search', _searchRole.default], ['searchbox', _searchboxRole.default], ['separator', _separatorRole.default], ['slider', _sliderRole.default], ['spinbutton', _spinbuttonRole.default], ['status', _statusRole.default], ['strong', _strongRole.default], ['subscript', _subscriptRole.default], ['superscript', _superscriptRole.default], ['switch', _switchRole.default], ['tab', _tabRole.default], ['table', _tableRole.default], ['tablist', _tablistRole.default], ['tabpanel', _tabpanelRole.default], ['term', _termRole.default], ['textbox', _textboxRole.default], ['time', _timeRole.default], ['timer', _timerRole.default], ['toolbar', _toolbarRole.default], ['tooltip', _tooltipRole.default], ['tree', _treeRole.default], ['treegrid', _treegridRole.default], ['treeitem', _treeitemRole.default]]; var _default$H = ariaLiteralRoles; ariaLiteralRoles$1.default = _default$H; var ariaDpubRoles$1 = {}; var docAbstractRole$1 = {}; Object.defineProperty(docAbstractRole$1, "__esModule", { value: true }); docAbstractRole$1.default = void 0; var docAbstractRole = { abstract: false, accessibleNameRequired: false, baseConcepts: [], childrenPresentational: false, nameFrom: ['author'], prohibitedProps: [], props: { 'aria-disabled': null, 'aria-errormessage': null, 'aria-expanded': null, 'aria-haspopup': null, 'aria-invalid': null }, relatedConcepts: [{ concept: { name: 'abstract [EPUB-SSV]' }, module: 'EPUB' }], requireContextRole: [], requiredContextRole: [], requiredOwnedElements: [], requiredProps: {}, superClass: [['roletype', 'structure', 'section']] }; var _default$G = docAbstractRole; docAbstractRole$1.default = _default$G; var docAcknowledgmentsRole$1 = {}; Object.defineProperty(docAcknowledgmentsRole$1, "__esModule", { value: true }); docAcknowledgmentsRole$1.default = void 0; var docAcknowledgmentsRole = { abstract: false, accessibleNameRequired: false, baseConcepts: [], childrenPresentational: false, nameFrom: ['author'], prohibitedProps: [], props: { 'aria-disabled': null, 'aria-errormessage': null, 'aria-expanded': null, 'aria-haspopup': null, 'aria-invalid': null }, relatedConcepts: [{ concept: { name: 'acknowledgments [EPUB-SSV]' }, module: 'EPUB' }], requireContextRole: [], requiredContextRole: [], requiredOwnedElements: [], requiredProps: {}, superClass: [['roletype', 'structure', 'section', 'landmark']] }; var _default$F = docAcknowledgmentsRole; docAcknowledgmentsRole$1.default = _default$F; var docAfterwordRole$1 = {}; Object.defineProperty(docAfterwordRole$1, "__esModule", { value: true }); docAfterwordRole$1.default = void 0; var docAfterwordRole = { abstract: false, accessibleNameRequired: false, baseConcepts: [], childrenPresentational: false, nameFrom: ['author'], prohibitedProps: [], props: { 'aria-disabled': null, 'aria-errormessage': null, 'aria-expanded': null, 'aria-haspopup': null, 'aria-invalid': null }, relatedConcepts: [{ concept: { name: 'afterword [EPUB-SSV]' }, module: 'EPUB' }], requireContextRole: [], requiredContextRole: [], requiredOwnedElements: [], requiredProps: {}, superClass: [['roletype', 'structure', 'section', 'landmark']] }; var _default$E = docAfterwordRole; docAfterwordRole$1.default = _default$E; var docAppendixRole$1 = {}; Object.defineProperty(docAppendixRole$1, "__esModule", { value: true }); docAppendixRole$1.default = void 0; var docAppendixRole = { abstract: false, accessibleNameRequired: false, baseConcepts: [], childrenPresentational: false, nameFrom: ['author'], prohibitedProps: [], props: { 'aria-disabled': null, 'aria-errormessage': null, 'aria-expanded': null, 'aria-haspopup': null, 'aria-invalid': null }, relatedConcepts: [{ concept: { name: 'appendix [EPUB-SSV]' }, module: 'EPUB' }], requireContextRole: [], requiredContextRole: [], requiredOwnedElements: [], requiredProps: {}, superClass: [['roletype', 'structure', 'section', 'landmark']] }; var _default$D = docAppendixRole; docAppendixRole$1.default = _default$D; var docBacklinkRole$1 = {}; Object.defineProperty(docBacklinkRole$1, "__esModule", { value: true }); docBacklinkRole$1.default = void 0; var docBacklinkRole = { abstract: false, accessibleNameRequired: true, baseConcepts: [], childrenPresentational: false, nameFrom: ['author', 'content'], prohibitedProps: [], props: { 'aria-errormessage': null, 'aria-invalid': null }, relatedConcepts: [{ concept: { name: 'referrer [EPUB-SSV]' }, module: 'EPUB' }], requireContextRole: [], requiredContextRole: [], requiredOwnedElements: [], requiredProps: {}, superClass: [['roletype', 'widget', 'command', 'link']] }; var _default$C = docBacklinkRole; docBacklinkRole$1.default = _default$C; var docBiblioentryRole$1 = {}; Object.defineProperty(docBiblioentryRole$1, "__esModule", { value: true }); docBiblioentryRole$1.default = void 0; var docBiblioentryRole = { abstract: false, accessibleNameRequired: true, baseConcepts: [], childrenPresentational: false, nameFrom: ['author'], prohibitedProps: [], props: { 'aria-disabled': null, 'aria-errormessage': null, 'aria-expanded': null, 'aria-haspopup': null, 'aria-invalid': null }, relatedConcepts: [{ concept: { name: 'EPUB biblioentry [EPUB-SSV]' }, module: 'EPUB' }], requireContextRole: ['doc-bibliography'], requiredContextRole: ['doc-bibliography'], requiredOwnedElements: [], requiredProps: {}, superClass: [['roletype', 'structure', 'section', 'listitem']] }; var _default$B = docBiblioentryRole; docBiblioentryRole$1.default = _default$B; var docBibliographyRole$1 = {}; Object.defineProperty(docBibliographyRole$1, "__esModule", { value: true }); docBibliographyRole$1.default = void 0; var docBibliographyRole = { abstract: false, accessibleNameRequired: false, baseConcepts: [], childrenPresentational: false, nameFrom: ['author'], prohibitedProps: [], props: { 'aria-disabled': null, 'aria-errormessage': null, 'aria-expanded': null, 'aria-haspopup': null, 'aria-invalid': null }, relatedConcepts: [{ concept: { name: 'bibliography [EPUB-SSV]' }, module: 'EPUB' }], requireContextRole: [], requiredContextRole: [], requiredOwnedElements: [['doc-biblioentry']], requiredProps: {}, superClass: [['roletype', 'structure', 'section', 'landmark']] }; var _default$A = docBibliographyRole; docBibliographyRole$1.default = _default$A; var docBibliorefRole$1 = {}; Object.defineProperty(docBibliorefRole$1, "__esModule", { value: true }); docBibliorefRole$1.default = void 0; var docBibliorefRole = { abstract: false, accessibleNameRequired: true, baseConcepts: [], childrenPresentational: false, nameFrom: ['author', 'contents'], prohibitedProps: [], props: { 'aria-errormessage': null, 'aria-invalid': null }, relatedConcepts: [{ concept: { name: 'biblioref [EPUB-SSV]' }, module: 'EPUB' }], requireContextRole: [], requiredContextRole: [], requiredOwnedElements: [], requiredProps: {}, superClass: [['roletype', 'widget', 'command', 'link']] }; var _default$z = docBibliorefRole; docBibliorefRole$1.default = _default$z; var docChapterRole$1 = {}; Object.defineProperty(docChapterRole$1, "__esModule", { value: true }); docChapterRole$1.default = void 0; var docChapterRole = { abstract: false, accessibleNameRequired: false, baseConcepts: [], childrenPresentational: false, nameFrom: ['author'], prohibitedProps: [], props: { 'aria-disabled': null, 'aria-errormessage': null, 'aria-expanded': null, 'aria-haspopup': null, 'aria-invalid': null }, relatedConcepts: [{ concept: { name: 'chapter [EPUB-SSV]' }, module: 'EPUB' }], requireContextRole: [], requiredContextRole: [], requiredOwnedElements: [], requiredProps: {}, superClass: [['roletype', 'structure', 'section', 'landmark']] }; var _default$y = docChapterRole; docChapterRole$1.default = _default$y; var docColophonRole$1 = {}; Object.defineProperty(docColophonRole$1, "__esModule", { value: true }); docColophonRole$1.default = void 0; var docColophonRole = { abstract: false, accessibleNameRequired: false, baseConcepts: [], childrenPresentational: false, nameFrom: ['author'], prohibitedProps: [], props: { 'aria-disabled': null, 'aria-errormessage': null, 'aria-expanded': null, 'aria-haspopup': null, 'aria-invalid': null }, relatedConcepts: [{ concept: { name: 'colophon [EPUB-SSV]' }, module: 'EPUB' }], requireContextRole: [], requiredContextRole: [], requiredOwnedElements: [], requiredProps: {}, superClass: [['roletype', 'structure', 'section']] }; var _default$x = docColophonRole; docColophonRole$1.default = _default$x; var docConclusionRole$1 = {}; Object.defineProperty(docConclusionRole$1, "__esModule", { value: true }); docConclusionRole$1.default = void 0; var docConclusionRole = { abstract: false, accessibleNameRequired: false, baseConcepts: [], childrenPresentational: false, nameFrom: ['author'], prohibitedProps: [], props: { 'aria-disabled': null, 'aria-errormessage': null, 'aria-expanded': null, 'aria-haspopup': null, 'aria-invalid': null }, relatedConcepts: [{ concept: { name: 'conclusion [EPUB-SSV]' }, module: 'EPUB' }], requireContextRole: [], requiredContextRole: [], requiredOwnedElements: [], requiredProps: {}, superClass: [['roletype', 'structure', 'section', 'landmark']] }; var _default$w = docConclusionRole; docConclusionRole$1.default = _default$w; var docCoverRole$1 = {}; Object.defineProperty(docCoverRole$1, "__esModule", { value: true }); docCoverRole$1.default = void 0; var docCoverRole = { abstract: false, accessibleNameRequired: false, baseConcepts: [], childrenPresentational: false, nameFrom: ['author'], prohibitedProps: [], props: { 'aria-disabled': null, 'aria-errormessage': null, 'aria-expanded': null, 'aria-haspopup': null, 'aria-invalid': null }, relatedConcepts: [{ concept: { name: 'cover [EPUB-SSV]' }, module: 'EPUB' }], requireContextRole: [], requiredContextRole: [], requiredOwnedElements: [], requiredProps: {}, superClass: [['roletype', 'structure', 'section', 'img']] }; var _default$v = docCoverRole; docCoverRole$1.default = _default$v; var docCreditRole$1 = {}; Object.defineProperty(docCreditRole$1, "__esModule", { value: true }); docCreditRole$1.default = void 0; var docCreditRole = { abstract: false, accessibleNameRequired: false, baseConcepts: [], childrenPresentational: false, nameFrom: ['author'], prohibitedProps: [], props: { 'aria-disabled': null, 'aria-errormessage': null, 'aria-expanded': null, 'aria-haspopup': null, 'aria-invalid': null }, relatedConcepts: [{ concept: { name: 'credit [EPUB-SSV]' }, module: 'EPUB' }], requireContextRole: [], requiredContextRole: [], requiredOwnedElements: [], requiredProps: {}, superClass: [['roletype', 'structure', 'section']] }; var _default$u = docCreditRole; docCreditRole$1.default = _default$u; var docCreditsRole$1 = {}; Object.defineProperty(docCreditsRole$1, "__esModule", { value: true }); docCreditsRole$1.default = void 0; var docCreditsRole = { abstract: false, accessibleNameRequired: false, baseConcepts: [], childrenPresentational: false, nameFrom: ['author'], prohibitedProps: [], props: { 'aria-disabled': null, 'aria-errormessage': null, 'aria-expanded': null, 'aria-haspopup': null, 'aria-invalid': null }, relatedConcepts: [{ concept: { name: 'credits [EPUB-SSV]' }, module: 'EPUB' }], requireContextRole: [], requiredContextRole: [], requiredOwnedElements: [], requiredProps: {}, superClass: [['roletype', 'structure', 'section', 'landmark']] }; var _default$t = docCreditsRole; docCreditsRole$1.default = _default$t; var docDedicationRole$1 = {}; Object.defineProperty(docDedicationRole$1, "__esModule", { value: true }); docDedicationRole$1.default = void 0; var docDedicationRole = { abstract: false, accessibleNameRequired: false, baseConcepts: [], childrenPresentational: false, nameFrom: ['author'], prohibitedProps: [], props: { 'aria-disabled': null, 'aria-errormessage': null, 'aria-expanded': null, 'aria-haspopup': null, 'aria-invalid': null }, relatedConcepts: [{ concept: { name: 'dedication [EPUB-SSV]' }, module: 'EPUB' }], requireContextRole: [], requiredContextRole: [], requiredOwnedElements: [], requiredProps: {}, superClass: [['roletype', 'structure', 'section']] }; var _default$s = docDedicationRole; docDedicationRole$1.default = _default$s; var docEndnoteRole$1 = {}; Object.defineProperty(docEndnoteRole$1, "__esModule", { value: true }); docEndnoteRole$1.default = void 0; var docEndnoteRole = { abstract: false, accessibleNameRequired: false, baseConcepts: [], childrenPresentational: false, nameFrom: ['author'], prohibitedProps: [], props: { 'aria-disabled': null, 'aria-errormessage': null, 'aria-expanded': null, 'aria-haspopup': null, 'aria-invalid': null }, relatedConcepts: [{ concept: { name: 'rearnote [EPUB-SSV]' }, module: 'EPUB' }], requireContextRole: ['doc-endnotes'], requiredContextRole: ['doc-endnotes'], requiredOwnedElements: [], requiredProps: {}, superClass: [['roletype', 'structure', 'section', 'listitem']] }; var _default$r = docEndnoteRole; docEndnoteRole$1.default = _default$r; var docEndnotesRole$1 = {}; Object.defineProperty(docEndnotesRole$1, "__esModule", { value: true }); docEndnotesRole$1.default = void 0; var docEndnotesRole = { abstract: false, accessibleNameRequired: false, baseConcepts: [], childrenPresentational: false, nameFrom: ['author'], prohibitedProps: [], props: { 'aria-disabled': null, 'aria-errormessage': null, 'aria-expanded': null, 'aria-haspopup': null, 'aria-invalid': null }, relatedConcepts: [{ concept: { name: 'rearnotes [EPUB-SSV]' }, module: 'EPUB' }], requireContextRole: [], requiredContextRole: [], requiredOwnedElements: [['doc-endnote']], requiredProps: {}, superClass: [['roletype', 'structure', 'section', 'landmark']] }; var _default$q = docEndnotesRole; docEndnotesRole$1.default = _default$q; var docEpigraphRole$1 = {}; Object.defineProperty(docEpigraphRole$1, "__esModule", { value: true }); docEpigraphRole$1.default = void 0; var docEpigraphRole = { abstract: false, accessibleNameRequired: false, baseConcepts: [], childrenPresentational: false, nameFrom: ['author'], prohibitedProps: [], props: { 'aria-disabled': null, 'aria-errormessage': null, 'aria-expanded': null, 'aria-haspopup': null, 'aria-invalid': null }, relatedConcepts: [{ concept: { name: 'epigraph [EPUB-SSV]' }, module: 'EPUB' }], requireContextRole: [], requiredContextRole: [], requiredOwnedElements: [], requiredProps: {}, superClass: [['roletype', 'structure', 'section']] }; var _default$p = docEpigraphRole; docEpigraphRole$1.default = _default$p; var docEpilogueRole$1 = {}; Object.defineProperty(docEpilogueRole$1, "__esModule", { value: true }); docEpilogueRole$1.default = void 0; var docEpilogueRole = { abstract: false, accessibleNameRequired: false, baseConcepts: [], childrenPresentational: false, nameFrom: ['author'], prohibitedProps: [], props: { 'aria-disabled': null, 'aria-errormessage': null, 'aria-expanded': null, 'aria-haspopup': null, 'aria-invalid': null }, relatedConcepts: [{ concept: { name: 'epilogue [EPUB-SSV]' }, module: 'EPUB' }], requireContextRole: [], requiredContextRole: [], requiredOwnedElements: [], requiredProps: {}, superClass: [['roletype', 'structure', 'section', 'landmark']] }; var _default$o = docEpilogueRole; docEpilogueRole$1.default = _default$o; var docErrataRole$1 = {}; Object.defineProperty(docErrataRole$1, "__esModule", { value: true }); docErrataRole$1.default = void 0; var docErrataRole = { abstract: false, accessibleNameRequired: false, baseConcepts: [], childrenPresentational: false, nameFrom: ['author'], prohibitedProps: [], props: { 'aria-disabled': null, 'aria-errormessage': null, 'aria-expanded': null, 'aria-haspopup': null, 'aria-invalid': null }, relatedConcepts: [{ concept: { name: 'errata [EPUB-SSV]' }, module: 'EPUB' }], requireContextRole: [], requiredContextRole: [], requiredOwnedElements: [], requiredProps: {}, superClass: [['roletype', 'structure', 'section', 'landmark']] }; var _default$n = docErrataRole; docErrataRole$1.default = _default$n; var docExampleRole$1 = {}; Object.defineProperty(docExampleRole$1, "__esModule", { value: true }); docExampleRole$1.default = void 0; var docExampleRole = { abstract: false, accessibleNameRequired: false, baseConcepts: [], childrenPresentational: false, nameFrom: ['author'], prohibitedProps: [], props: { 'aria-disabled': null, 'aria-errormessage': null, 'aria-expanded': null, 'aria-haspopup': null, 'aria-invalid': null }, relatedConcepts: [], requireContextRole: [], requiredContextRole: [], requiredOwnedElements: [], requiredProps: {}, superClass: [['roletype', 'structure', 'section']] }; var _default$m = docExampleRole; docExampleRole$1.default = _default$m; var docFootnoteRole$1 = {}; Object.defineProperty(docFootnoteRole$1, "__esModule", { value: true }); docFootnoteRole$1.default = void 0; var docFootnoteRole = { abstract: false, accessibleNameRequired: false, baseConcepts: [], childrenPresentational: false, nameFrom: ['author'], prohibitedProps: [], props: { 'aria-disabled': null, 'aria-errormessage': null, 'aria-expanded': null, 'aria-haspopup': null, 'aria-invalid': null }, relatedConcepts: [{ concept: { name: 'footnote [EPUB-SSV]' }, module: 'EPUB' }], requireContextRole: [], requiredContextRole: [], requiredOwnedElements: [], requiredProps: {}, superClass: [['roletype', 'structure', 'section']] }; var _default$l = docFootnoteRole; docFootnoteRole$1.default = _default$l; var docForewordRole$1 = {}; Object.defineProperty(docForewordRole$1, "__esModule", { value: true }); docForewordRole$1.default = void 0; var docForewordRole = { abstract: false, accessibleNameRequired: false, baseConcepts: [], childrenPresentational: false, nameFrom: ['author'], prohibitedProps: [], props: { 'aria-disabled': null, 'aria-errormessage': null, 'aria-expanded': null, 'aria-haspopup': null, 'aria-invalid': null }, relatedConcepts: [{ concept: { name: 'foreword [EPUB-SSV]' }, module: 'EPUB' }], requireContextRole: [], requiredContextRole: [], requiredOwnedElements: [], requiredProps: {}, superClass: [['roletype', 'structure', 'section', 'landmark']] }; var _default$k = docForewordRole; docForewordRole$1.default = _default$k; var docGlossaryRole$1 = {}; Object.defineProperty(docGlossaryRole$1, "__esModule", { value: true }); docGlossaryRole$1.default = void 0; var docGlossaryRole = { abstract: false, accessibleNameRequired: false, baseConcepts: [], childrenPresentational: false, nameFrom: ['author'], prohibitedProps: [], props: { 'aria-disabled': null, 'aria-errormessage': null, 'aria-expanded': null, 'aria-haspopup': null, 'aria-invalid': null }, relatedConcepts: [{ concept: { name: 'glossary [EPUB-SSV]' }, module: 'EPUB' }], requireContextRole: [], requiredContextRole: [], requiredOwnedElements: [['definition'], ['term']], requiredProps: {}, superClass: [['roletype', 'structure', 'section', 'landmark']] }; var _default$j = docGlossaryRole; docGlossaryRole$1.default = _default$j; var docGlossrefRole$1 = {}; Object.defineProperty(docGlossrefRole$1, "__esModule", { value: true }); docGlossrefRole$1.default = void 0; var docGlossrefRole = { abstract: false, accessibleNameRequired: true, baseConcepts: [], childrenPresentational: false, nameFrom: ['author', 'contents'], prohibitedProps: [], props: { 'aria-errormessage': null, 'aria-invalid': null }, relatedConcepts: [{ concept: { name: 'glossref [EPUB-SSV]' }, module: 'EPUB' }], requireContextRole: [], requiredContextRole: [], requiredOwnedElements: [], requiredProps: {}, superClass: [['roletype', 'widget', 'command', 'link']] }; var _default$i = docGlossrefRole; docGlossrefRole$1.default = _default$i; var docIndexRole$1 = {}; Object.defineProperty(docIndexRole$1, "__esModule", { value: true }); docIndexRole$1.default = void 0; var docIndexRole = { abstract: false, accessibleNameRequired: false, baseConcepts: [], childrenPresentational: false, nameFrom: ['author'], prohibitedProps: [], props: { 'aria-disabled': null, 'aria-errormessage': null, 'aria-expanded': null, 'aria-haspopup': null, 'aria-invalid': null }, relatedConcepts: [{ concept: { name: 'index [EPUB-SSV]' }, module: 'EPUB' }], requireContextRole: [], requiredContextRole: [], requiredOwnedElements: [], requiredProps: {}, superClass: [['roletype', 'structure', 'section', 'landmark', 'navigation']] }; var _default$h = docIndexRole; docIndexRole$1.default = _default$h; var docIntroductionRole$1 = {}; Object.defineProperty(docIntroductionRole$1, "__esModule", { value: true }); docIntroductionRole$1.default = void 0; var docIntroductionRole = { abstract: false, accessibleNameRequired: false, baseConcepts: [], childrenPresentational: false, nameFrom: ['author'], prohibitedProps: [], props: { 'aria-disabled': null, 'aria-errormessage': null, 'aria-expanded': null, 'aria-haspopup': null, 'aria-invalid': null }, relatedConcepts: [{ concept: { name: 'introduction [EPUB-SSV]' }, module: 'EPUB' }], requireContextRole: [], requiredContextRole: [], requiredOwnedElements: [], requiredProps: {}, superClass: [['roletype', 'structure', 'section', 'landmark']] }; var _default$g = docIntroductionRole; docIntroductionRole$1.default = _default$g; var docNoterefRole$1 = {}; Object.defineProperty(docNoterefRole$1, "__esModule", { value: true }); docNoterefRole$1.default = void 0; var docNoterefRole = { abstract: false, accessibleNameRequired: true, baseConcepts: [], childrenPresentational: false, nameFrom: ['author', 'contents'], prohibitedProps: [], props: { 'aria-errormessage': null, 'aria-invalid': null }, relatedConcepts: [{ concept: { name: 'noteref [EPUB-SSV]' }, module: 'EPUB' }], requireContextRole: [], requiredContextRole: [], requiredOwnedElements: [], requiredProps: {}, superClass: [['roletype', 'widget', 'command', 'link']] }; var _default$f = docNoterefRole; docNoterefRole$1.default = _default$f; var docNoticeRole$1 = {}; Object.defineProperty(docNoticeRole$1, "__esModule", { value: true }); docNoticeRole$1.default = void 0; var docNoticeRole = { abstract: false, accessibleNameRequired: false, baseConcepts: [], childrenPresentational: false, nameFrom: ['author'], prohibitedProps: [], props: { 'aria-disabled': null, 'aria-errormessage': null, 'aria-expanded': null, 'aria-haspopup': null, 'aria-invalid': null }, relatedConcepts: [{ concept: { name: 'notice [EPUB-SSV]' }, module: 'EPUB' }], requireContextRole: [], requiredContextRole: [], requiredOwnedElements: [], requiredProps: {}, superClass: [['roletype', 'structure', 'section', 'note']] }; var _default$e = docNoticeRole; docNoticeRole$1.default = _default$e; var docPagebreakRole$1 = {}; Object.defineProperty(docPagebreakRole$1, "__esModule", { value: true }); docPagebreakRole$1.default = void 0; var docPagebreakRole = { abstract: false, accessibleNameRequired: true, baseConcepts: [], childrenPresentational: true, nameFrom: ['author'], prohibitedProps: [], props: { 'aria-errormessage': null, 'aria-expanded': null, 'aria-haspopup': null, 'aria-invalid': null }, relatedConcepts: [{ concept: { name: 'pagebreak [EPUB-SSV]' }, module: 'EPUB' }], requireContextRole: [], requiredContextRole: [], requiredOwnedElements: [], requiredProps: {}, superClass: [['roletype', 'structure', 'separator']] }; var _default$d = docPagebreakRole; docPagebreakRole$1.default = _default$d; var docPagelistRole$1 = {}; Object.defineProperty(docPagelistRole$1, "__esModule", { value: true }); docPagelistRole$1.default = void 0; var docPagelistRole = { abstract: false, accessibleNameRequired: false, baseConcepts: [], childrenPresentational: false, nameFrom: ['author'], prohibitedProps: [], props: { 'aria-disabled': null, 'aria-errormessage': null, 'aria-expanded': null, 'aria-haspopup': null, 'aria-invalid': null }, relatedConcepts: [{ concept: { name: 'page-list [EPUB-SSV]' }, module: 'EPUB' }], requireContextRole: [], requiredContextRole: [], requiredOwnedElements: [], requiredProps: {}, superClass: [['roletype', 'structure', 'section', 'landmark', 'navigation']] }; var _default$c = docPagelistRole; docPagelistRole$1.default = _default$c; var docPartRole$1 = {}; Object.defineProperty(docPartRole$1, "__esModule", { value: true }); docPartRole$1.default = void 0; var docPartRole = { abstract: false, accessibleNameRequired: true, baseConcepts: [], childrenPresentational: false, nameFrom: ['author'], prohibitedProps: [], props: { 'aria-disabled': null, 'aria-errormessage': null, 'aria-expanded': null, 'aria-haspopup': null, 'aria-invalid': null }, relatedConcepts: [{ concept: { name: 'part [EPUB-SSV]' }, module: 'EPUB' }], requireContextRole: [], requiredContextRole: [], requiredOwnedElements: [], requiredProps: {}, superClass: [['roletype', 'structure', 'section', 'landmark']] }; var _default$b = docPartRole; docPartRole$1.default = _default$b; var docPrefaceRole$1 = {}; Object.defineProperty(docPrefaceRole$1, "__esModule", { value: true }); docPrefaceRole$1.default = void 0; var docPrefaceRole = { abstract: false, accessibleNameRequired: false, baseConcepts: [], childrenPresentational: false, nameFrom: ['author'], prohibitedProps: [], props: { 'aria-disabled': null, 'aria-errormessage': null, 'aria-expanded': null, 'aria-haspopup': null, 'aria-invalid': null }, relatedConcepts: [{ concept: { name: 'preface [EPUB-SSV]' }, module: 'EPUB' }], requireContextRole: [], requiredContextRole: [], requiredOwnedElements: [], requiredProps: {}, superClass: [['roletype', 'structure', 'section', 'landmark']] }; var _default$a = docPrefaceRole; docPrefaceRole$1.default = _default$a; var docPrologueRole$1 = {}; Object.defineProperty(docPrologueRole$1, "__esModule", { value: true }); docPrologueRole$1.default = void 0; var docPrologueRole = { abstract: false, accessibleNameRequired: false, baseConcepts: [], childrenPresentational: false, nameFrom: ['author'], prohibitedProps: [], props: { 'aria-disabled': null, 'aria-errormessage': null, 'aria-expanded': null, 'aria-haspopup': null, 'aria-invalid': null }, relatedConcepts: [{ concept: { name: 'prologue [EPUB-SSV]' }, module: 'EPUB' }], requireContextRole: [], requiredContextRole: [], requiredOwnedElements: [], requiredProps: {}, superClass: [['roletype', 'structure', 'section', 'landmark']] }; var _default$9 = docPrologueRole; docPrologueRole$1.default = _default$9; var docPullquoteRole$1 = {}; Object.defineProperty(docPullquoteRole$1, "__esModule", { value: true }); docPullquoteRole$1.default = void 0; var docPullquoteRole = { abstract: false, accessibleNameRequired: false, baseConcepts: [], childrenPresentational: false, nameFrom: ['author'], prohibitedProps: [], props: {}, relatedConcepts: [{ concept: { name: 'pullquote [EPUB-SSV]' }, module: 'EPUB' }], requireContextRole: [], requiredContextRole: [], requiredOwnedElements: [], requiredProps: {}, superClass: [['none']] }; var _default$8 = docPullquoteRole; docPullquoteRole$1.default = _default$8; var docQnaRole$1 = {}; Object.defineProperty(docQnaRole$1, "__esModule", { value: true }); docQnaRole$1.default = void 0; var docQnaRole = { abstract: false, accessibleNameRequired: false, baseConcepts: [], childrenPresentational: false, nameFrom: ['author'], prohibitedProps: [], props: { 'aria-disabled': null, 'aria-errormessage': null, 'aria-expanded': null, 'aria-haspopup': null, 'aria-invalid': null }, relatedConcepts: [{ concept: { name: 'qna [EPUB-SSV]' }, module: 'EPUB' }], requireContextRole: [], requiredContextRole: [], requiredOwnedElements: [], requiredProps: {}, superClass: [['roletype', 'structure', 'section']] }; var _default$7 = docQnaRole; docQnaRole$1.default = _default$7; var docSubtitleRole$1 = {}; Object.defineProperty(docSubtitleRole$1, "__esModule", { value: true }); docSubtitleRole$1.default = void 0; var docSubtitleRole = { abstract: false, accessibleNameRequired: false, baseConcepts: [], childrenPresentational: false, nameFrom: ['author'], prohibitedProps: [], props: { 'aria-disabled': null, 'aria-errormessage': null, 'aria-expanded': null, 'aria-haspopup': null, 'aria-invalid': null }, relatedConcepts: [{ concept: { name: 'subtitle [EPUB-SSV]' }, module: 'EPUB' }], requireContextRole: [], requiredContextRole: [], requiredOwnedElements: [], requiredProps: {}, superClass: [['roletype', 'structure', 'sectionhead']] }; var _default$6 = docSubtitleRole; docSubtitleRole$1.default = _default$6; var docTipRole$1 = {}; Object.defineProperty(docTipRole$1, "__esModule", { value: true }); docTipRole$1.default = void 0; var docTipRole = { abstract: false, accessibleNameRequired: false, baseConcepts: [], childrenPresentational: false, nameFrom: ['author'], prohibitedProps: [], props: { 'aria-disabled': null, 'aria-errormessage': null, 'aria-expanded': null, 'aria-haspopup': null, 'aria-invalid': null }, relatedConcepts: [{ concept: { name: 'help [EPUB-SSV]' }, module: 'EPUB' }], requireContextRole: [], requiredContextRole: [], requiredOwnedElements: [], requiredProps: {}, superClass: [['roletype', 'structure', 'section', 'note']] }; var _default$5 = docTipRole; docTipRole$1.default = _default$5; var docTocRole$1 = {}; Object.defineProperty(docTocRole$1, "__esModule", { value: true }); docTocRole$1.default = void 0; var docTocRole = { abstract: false, accessibleNameRequired: false, baseConcepts: [], childrenPresentational: false, nameFrom: ['author'], prohibitedProps: [], props: { 'aria-disabled': null, 'aria-errormessage': null, 'aria-expanded': null, 'aria-haspopup': null, 'aria-invalid': null }, relatedConcepts: [{ concept: { name: 'toc [EPUB-SSV]' }, module: 'EPUB' }], requireContextRole: [], requiredContextRole: [], requiredOwnedElements: [], requiredProps: {}, superClass: [['roletype', 'structure', 'section', 'landmark', 'navigation']] }; var _default$4 = docTocRole; docTocRole$1.default = _default$4; Object.defineProperty(ariaDpubRoles$1, "__esModule", { value: true }); ariaDpubRoles$1.default = void 0; var _docAbstractRole = _interopRequireDefault$4(docAbstractRole$1); var _docAcknowledgmentsRole = _interopRequireDefault$4(docAcknowledgmentsRole$1); var _docAfterwordRole = _interopRequireDefault$4(docAfterwordRole$1); var _docAppendixRole = _interopRequireDefault$4(docAppendixRole$1); var _docBacklinkRole = _interopRequireDefault$4(docBacklinkRole$1); var _docBiblioentryRole = _interopRequireDefault$4(docBiblioentryRole$1); var _docBibliographyRole = _interopRequireDefault$4(docBibliographyRole$1); var _docBibliorefRole = _interopRequireDefault$4(docBibliorefRole$1); var _docChapterRole = _interopRequireDefault$4(docChapterRole$1); var _docColophonRole = _interopRequireDefault$4(docColophonRole$1); var _docConclusionRole = _interopRequireDefault$4(docConclusionRole$1); var _docCoverRole = _interopRequireDefault$4(docCoverRole$1); var _docCreditRole = _interopRequireDefault$4(docCreditRole$1); var _docCreditsRole = _interopRequireDefault$4(docCreditsRole$1); var _docDedicationRole = _interopRequireDefault$4(docDedicationRole$1); var _docEndnoteRole = _interopRequireDefault$4(docEndnoteRole$1); var _docEndnotesRole = _interopRequireDefault$4(docEndnotesRole$1); var _docEpigraphRole = _interopRequireDefault$4(docEpigraphRole$1); var _docEpilogueRole = _interopRequireDefault$4(docEpilogueRole$1); var _docErrataRole = _interopRequireDefault$4(docErrataRole$1); var _docExampleRole = _interopRequireDefault$4(docExampleRole$1); var _docFootnoteRole = _interopRequireDefault$4(docFootnoteRole$1); var _docForewordRole = _interopRequireDefault$4(docForewordRole$1); var _docGlossaryRole = _interopRequireDefault$4(docGlossaryRole$1); var _docGlossrefRole = _interopRequireDefault$4(docGlossrefRole$1); var _docIndexRole = _interopRequireDefault$4(docIndexRole$1); var _docIntroductionRole = _interopRequireDefault$4(docIntroductionRole$1); var _docNoterefRole = _interopRequireDefault$4(docNoterefRole$1); var _docNoticeRole = _interopRequireDefault$4(docNoticeRole$1); var _docPagebreakRole = _interopRequireDefault$4(docPagebreakRole$1); var _docPagelistRole = _interopRequireDefault$4(docPagelistRole$1); var _docPartRole = _interopRequireDefault$4(docPartRole$1); var _docPrefaceRole = _interopRequireDefault$4(docPrefaceRole$1); var _docPrologueRole = _interopRequireDefault$4(docPrologueRole$1); var _docPullquoteRole = _interopRequireDefault$4(docPullquoteRole$1); var _docQnaRole = _interopRequireDefault$4(docQnaRole$1); var _docSubtitleRole = _interopRequireDefault$4(docSubtitleRole$1); var _docTipRole = _interopRequireDefault$4(docTipRole$1); var _docTocRole = _interopRequireDefault$4(docTocRole$1); function _interopRequireDefault$4(obj) { return obj && obj.__esModule ? obj : { default: obj }; } var ariaDpubRoles = [['doc-abstract', _docAbstractRole.default], ['doc-acknowledgments', _docAcknowledgmentsRole.default], ['doc-afterword', _docAfterwordRole.default], ['doc-appendix', _docAppendixRole.default], ['doc-backlink', _docBacklinkRole.default], ['doc-biblioentry', _docBiblioentryRole.default], ['doc-bibliography', _docBibliographyRole.default], ['doc-biblioref', _docBibliorefRole.default], ['doc-chapter', _docChapterRole.default], ['doc-colophon', _docColophonRole.default], ['doc-conclusion', _docConclusionRole.default], ['doc-cover', _docCoverRole.default], ['doc-credit', _docCreditRole.default], ['doc-credits', _docCreditsRole.default], ['doc-dedication', _docDedicationRole.default], ['doc-endnote', _docEndnoteRole.default], ['doc-endnotes', _docEndnotesRole.default], ['doc-epigraph', _docEpigraphRole.default], ['doc-epilogue', _docEpilogueRole.default], ['doc-errata', _docErrataRole.default], ['doc-example', _docExampleRole.default], ['doc-footnote', _docFootnoteRole.default], ['doc-foreword', _docForewordRole.default], ['doc-glossary', _docGlossaryRole.default], ['doc-glossref', _docGlossrefRole.default], ['doc-index', _docIndexRole.default], ['doc-introduction', _docIntroductionRole.default], ['doc-noteref', _docNoterefRole.default], ['doc-notice', _docNoticeRole.default], ['doc-pagebreak', _docPagebreakRole.default], ['doc-pagelist', _docPagelistRole.default], ['doc-part', _docPartRole.default], ['doc-preface', _docPrefaceRole.default], ['doc-prologue', _docPrologueRole.default], ['doc-pullquote', _docPullquoteRole.default], ['doc-qna', _docQnaRole.default], ['doc-subtitle', _docSubtitleRole.default], ['doc-tip', _docTipRole.default], ['doc-toc', _docTocRole.default]]; var _default$3 = ariaDpubRoles; ariaDpubRoles$1.default = _default$3; Object.defineProperty(rolesMap$1, "__esModule", { value: true }); rolesMap$1.default = void 0; var _ariaAbstractRoles = _interopRequireDefault$3(ariaAbstractRoles$1); var _ariaLiteralRoles = _interopRequireDefault$3(ariaLiteralRoles$1); var _ariaDpubRoles = _interopRequireDefault$3(ariaDpubRoles$1); function _interopRequireDefault$3(obj) { return obj && obj.__esModule ? obj : { default: obj }; } function _defineProperty(obj, key, value) { if (key in obj) { Object.defineProperty(obj, key, { value: value, enumerable: true, configurable: true, writable: true }); } else { obj[key] = value; } return obj; } function _createForOfIteratorHelper(o, allowArrayLike) { var it = typeof Symbol !== "undefined" && o[Symbol.iterator] || o["@@iterator"]; if (!it) { if (Array.isArray(o) || (it = _unsupportedIterableToArray$2(o)) || allowArrayLike && o && typeof o.length === "number") { if (it) o = it; var i = 0; var F = function F() {}; return { s: F, n: function n() { if (i >= o.length) return { done: true }; return { done: false, value: o[i++] }; }, e: function e(_e2) { throw _e2; }, f: F }; } throw new TypeError("Invalid attempt to iterate non-iterable instance.\nIn order to be iterable, non-array objects must have a [Symbol.iterator]() method."); } var normalCompletion = true, didErr = false, err; return { s: function s() { it = it.call(o); }, n: function n() { var step = it.next(); normalCompletion = step.done; return step; }, e: function e(_e3) { didErr = true; err = _e3; }, f: function f() { try { if (!normalCompletion && it.return != null) it.return(); } finally { if (didErr) throw err; } } }; } function _slicedToArray$2(arr, i) { return _arrayWithHoles$2(arr) || _iterableToArrayLimit$2(arr, i) || _unsupportedIterableToArray$2(arr, i) || _nonIterableRest$2(); } function _nonIterableRest$2() { throw new TypeError("Invalid attempt to destructure non-iterable instance.\nIn order to be iterable, non-array objects must have a [Symbol.iterator]() method."); } function _unsupportedIterableToArray$2(o, minLen) { if (!o) return; if (typeof o === "string") return _arrayLikeToArray$2(o, minLen); var n = Object.prototype.toString.call(o).slice(8, -1); if (n === "Object" && o.constructor) n = o.constructor.name; if (n === "Map" || n === "Set") return Array.from(o); if (n === "Arguments" || /^(?:Ui|I)nt(?:8|16|32)(?:Clamped)?Array$/.test(n)) return _arrayLikeToArray$2(o, minLen); } function _arrayLikeToArray$2(arr, len) { if (len == null || len > arr.length) len = arr.length; for (var i = 0, arr2 = new Array(len); i < len; i++) { arr2[i] = arr[i]; } return arr2; } function _iterableToArrayLimit$2(arr, i) { var _i = arr == null ? null : typeof Symbol !== "undefined" && arr[Symbol.iterator] || arr["@@iterator"]; if (_i == null) return; var _arr = []; var _n = true; var _d = false; var _s, _e; try { for (_i = _i.call(arr); !(_n = (_s = _i.next()).done); _n = true) { _arr.push(_s.value); if (i && _arr.length === i) break; } } catch (err) { _d = true; _e = err; } finally { try { if (!_n && _i["return"] != null) _i["return"](); } finally { if (_d) throw _e; } } return _arr; } function _arrayWithHoles$2(arr) { if (Array.isArray(arr)) return arr; } var roles$1 = [].concat(_ariaAbstractRoles.default, _ariaLiteralRoles.default, _ariaDpubRoles.default); roles$1.forEach(function (_ref) { var _ref2 = _slicedToArray$2(_ref, 2), roleDefinition = _ref2[1]; // Conglomerate the properties var _iterator = _createForOfIteratorHelper(roleDefinition.superClass), _step; try { for (_iterator.s(); !(_step = _iterator.n()).done;) { var superClassIter = _step.value; var _iterator2 = _createForOfIteratorHelper(superClassIter), _step2; try { var _loop = function _loop() { var superClassName = _step2.value; var superClassRoleTuple = roles$1.find(function (_ref3) { var _ref4 = _slicedToArray$2(_ref3, 1), name = _ref4[0]; return name === superClassName; }); if (superClassRoleTuple) { var superClassDefinition = superClassRoleTuple[1]; for (var _i2 = 0, _Object$keys = Object.keys(superClassDefinition.props); _i2 < _Object$keys.length; _i2++) { var prop = _Object$keys[_i2]; if ( // $FlowIssue Accessing the hasOwnProperty on the Object prototype is fine. !Object.prototype.hasOwnProperty.call(roleDefinition.props, prop)) { Object.assign(roleDefinition.props, _defineProperty({}, prop, superClassDefinition.props[prop])); } } } }; for (_iterator2.s(); !(_step2 = _iterator2.n()).done;) { _loop(); } } catch (err) { _iterator2.e(err); } finally { _iterator2.f(); } } } catch (err) { _iterator.e(err); } finally { _iterator.f(); } }); var rolesMap = { entries: function entries() { return roles$1; }, get: function get(key) { var item = roles$1.find(function (tuple) { return tuple[0] === key ? true : false; }); return item && item[1]; }, has: function has(key) { return !!this.get(key); }, keys: function keys() { return roles$1.map(function (_ref5) { var _ref6 = _slicedToArray$2(_ref5, 1), key = _ref6[0]; return key; }); }, values: function values() { return roles$1.map(function (_ref7) { var _ref8 = _slicedToArray$2(_ref7, 2), values = _ref8[1]; return values; }); } }; var _default$2 = rolesMap; rolesMap$1.default = _default$2; var elementRoleMap$1 = {}; Object.defineProperty(elementRoleMap$1, "__esModule", { value: true }); elementRoleMap$1.default = void 0; var _rolesMap$2 = _interopRequireDefault$2(rolesMap$1); function _interopRequireDefault$2(obj) { return obj && obj.__esModule ? obj : { default: obj }; } function _slicedToArray$1(arr, i) { return _arrayWithHoles$1(arr) || _iterableToArrayLimit$1(arr, i) || _unsupportedIterableToArray$1(arr, i) || _nonIterableRest$1(); } function _nonIterableRest$1() { throw new TypeError("Invalid attempt to destructure non-iterable instance.\nIn order to be iterable, non-array objects must have a [Symbol.iterator]() method."); } function _unsupportedIterableToArray$1(o, minLen) { if (!o) return; if (typeof o === "string") return _arrayLikeToArray$1(o, minLen); var n = Object.prototype.toString.call(o).slice(8, -1); if (n === "Object" && o.constructor) n = o.constructor.name; if (n === "Map" || n === "Set") return Array.from(o); if (n === "Arguments" || /^(?:Ui|I)nt(?:8|16|32)(?:Clamped)?Array$/.test(n)) return _arrayLikeToArray$1(o, minLen); } function _arrayLikeToArray$1(arr, len) { if (len == null || len > arr.length) len = arr.length; for (var i = 0, arr2 = new Array(len); i < len; i++) { arr2[i] = arr[i]; } return arr2; } function _iterableToArrayLimit$1(arr, i) { var _i = arr == null ? null : typeof Symbol !== "undefined" && arr[Symbol.iterator] || arr["@@iterator"]; if (_i == null) return; var _arr = []; var _n = true; var _d = false; var _s, _e; try { for (_i = _i.call(arr); !(_n = (_s = _i.next()).done); _n = true) { _arr.push(_s.value); if (i && _arr.length === i) break; } } catch (err) { _d = true; _e = err; } finally { try { if (!_n && _i["return"] != null) _i["return"](); } finally { if (_d) throw _e; } } return _arr; } function _arrayWithHoles$1(arr) { if (Array.isArray(arr)) return arr; } var elementRoles$1 = []; var keys$1 = _rolesMap$2.default.keys(); for (var i$1 = 0; i$1 < keys$1.length; i$1++) { var _key = keys$1[i$1]; var role = _rolesMap$2.default.get(_key); if (role) { var concepts = [].concat(role.baseConcepts, role.relatedConcepts); for (var k = 0; k < concepts.length; k++) { var relation = concepts[k]; if (relation.module === 'HTML') { var concept = relation.concept; if (concept) { (function () { var conceptStr = JSON.stringify(concept); var elementRoleRelation = elementRoles$1.find(function (relation) { return JSON.stringify(relation[0]) === conceptStr; }); var roles = void 0; if (elementRoleRelation) { roles = elementRoleRelation[1]; } else { roles = []; } var isUnique = true; for (var _i = 0; _i < roles.length; _i++) { if (roles[_i] === _key) { isUnique = false; break; } } if (isUnique) { roles.push(_key); } elementRoles$1.push([concept, roles]); })(); } } } } } var elementRoleMap = { entries: function entries() { return elementRoles$1; }, get: function get(key) { var item = elementRoles$1.find(function (tuple) { return JSON.stringify(tuple[0]) === JSON.stringify(key) ? true : false; }); return item && item[1]; }, has: function has(key) { return !!this.get(key); }, keys: function keys() { return elementRoles$1.map(function (_ref) { var _ref2 = _slicedToArray$1(_ref, 1), key = _ref2[0]; return key; }); }, values: function values() { return elementRoles$1.map(function (_ref3) { var _ref4 = _slicedToArray$1(_ref3, 2), values = _ref4[1]; return values; }); } }; var _default$1 = elementRoleMap; elementRoleMap$1.default = _default$1; var roleElementMap$1 = {}; Object.defineProperty(roleElementMap$1, "__esModule", { value: true }); roleElementMap$1.default = void 0; var _rolesMap$1 = _interopRequireDefault$1(rolesMap$1); function _interopRequireDefault$1(obj) { return obj && obj.__esModule ? obj : { default: obj }; } function _slicedToArray(arr, i) { return _arrayWithHoles(arr) || _iterableToArrayLimit(arr, i) || _unsupportedIterableToArray(arr, i) || _nonIterableRest(); } function _nonIterableRest() { throw new TypeError("Invalid attempt to destructure non-iterable instance.\nIn order to be iterable, non-array objects must have a [Symbol.iterator]() method."); } function _unsupportedIterableToArray(o, minLen) { if (!o) return; if (typeof o === "string") return _arrayLikeToArray(o, minLen); var n = Object.prototype.toString.call(o).slice(8, -1); if (n === "Object" && o.constructor) n = o.constructor.name; if (n === "Map" || n === "Set") return Array.from(o); if (n === "Arguments" || /^(?:Ui|I)nt(?:8|16|32)(?:Clamped)?Array$/.test(n)) return _arrayLikeToArray(o, minLen); } function _arrayLikeToArray(arr, len) { if (len == null || len > arr.length) len = arr.length; for (var i = 0, arr2 = new Array(len); i < len; i++) { arr2[i] = arr[i]; } return arr2; } function _iterableToArrayLimit(arr, i) { var _i = arr == null ? null : typeof Symbol !== "undefined" && arr[Symbol.iterator] || arr["@@iterator"]; if (_i == null) return; var _arr = []; var _n = true; var _d = false; var _s, _e; try { for (_i = _i.call(arr); !(_n = (_s = _i.next()).done); _n = true) { _arr.push(_s.value); if (i && _arr.length === i) break; } } catch (err) { _d = true; _e = err; } finally { try { if (!_n && _i["return"] != null) _i["return"](); } finally { if (_d) throw _e; } } return _arr; } function _arrayWithHoles(arr) { if (Array.isArray(arr)) return arr; } var roleElement = []; var keys = _rolesMap$1.default.keys(); var _loop = function _loop(i) { var key = keys[i]; var role = _rolesMap$1.default.get(key); if (role) { var concepts = [].concat(role.baseConcepts, role.relatedConcepts); for (var k = 0; k < concepts.length; k++) { var relation = concepts[k]; if (relation.module === 'HTML') { var concept = relation.concept; if (concept) { var roleElementRelation = roleElement.find(function (item) { return item[0] === key; }); var relationConcepts = void 0; if (roleElementRelation) { relationConcepts = roleElementRelation[1]; } else { relationConcepts = []; } relationConcepts.push(concept); roleElement.push([key, relationConcepts]); } } } } }; for (var i = 0; i < keys.length; i++) { _loop(i); } var roleElementMap = { entries: function entries() { return roleElement; }, get: function get(key) { var item = roleElement.find(function (tuple) { return tuple[0] === key ? true : false; }); return item && item[1]; }, has: function has(key) { return !!this.get(key); }, keys: function keys() { return roleElement.map(function (_ref) { var _ref2 = _slicedToArray(_ref, 1), key = _ref2[0]; return key; }); }, values: function values() { return roleElement.map(function (_ref3) { var _ref4 = _slicedToArray(_ref3, 2), values = _ref4[1]; return values; }); } }; var _default = roleElementMap; roleElementMap$1.default = _default; Object.defineProperty(lib, "__esModule", { value: true }); var roleElements_1 = lib.roleElements = elementRoles_1 = lib.elementRoles = roles_1 = lib.roles = lib.dom = lib.aria = void 0; var _ariaPropsMap = _interopRequireDefault(ariaPropsMap$1); var _domMap = _interopRequireDefault(domMap$1); var _rolesMap = _interopRequireDefault(rolesMap$1); var _elementRoleMap = _interopRequireDefault(elementRoleMap$1); var _roleElementMap = _interopRequireDefault(roleElementMap$1); function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; } var aria = _ariaPropsMap.default; lib.aria = aria; var dom = _domMap.default; lib.dom = dom; var roles = _rolesMap.default; var roles_1 = lib.roles = roles; var elementRoles = _elementRoleMap.default; var elementRoles_1 = lib.elementRoles = elementRoles; var roleElements = _roleElementMap.default; roleElements_1 = lib.roleElements = roleElements; const elementRoleList = buildElementRoleList(elementRoles_1); /** * @param {Element} element - * @returns {boolean} - `true` if `element` and its subtree are inaccessible */ function isSubtreeInaccessible(element) { if (element.hidden === true) { return true; } if (element.getAttribute('aria-hidden') === 'true') { return true; } const window = element.ownerDocument.defaultView; if (window.getComputedStyle(element).display === 'none') { return true; } return false; } /** * Partial implementation https://www.w3.org/TR/wai-aria-1.2/#tree_exclusion * which should only be used for elements with a non-presentational role i.e. * `role="none"` and `role="presentation"` will not be excluded. * * Implements aria-hidden semantics (i.e. parent overrides child) * Ignores "Child Presentational: True" characteristics * * @param {Element} element - * @param {object} [options] - * @param {function (element: Element): boolean} options.isSubtreeInaccessible - * can be used to return cached results from previous isSubtreeInaccessible calls * @returns {boolean} true if excluded, otherwise false */ function isInaccessible(element, options) { if (options === void 0) { options = {}; } const { isSubtreeInaccessible: isSubtreeInaccessibleImpl = isSubtreeInaccessible } = options; const window = element.ownerDocument.defaultView; // since visibility is inherited we can exit early if (window.getComputedStyle(element).visibility === 'hidden') { return true; } let currentElement = element; while (currentElement) { if (isSubtreeInaccessibleImpl(currentElement)) { return true; } currentElement = currentElement.parentElement; } return false; } function getImplicitAriaRoles(currentNode) { // eslint bug here: // eslint-disable-next-line no-unused-vars for (const { match, roles } of elementRoleList) { if (match(currentNode)) { return [...roles]; } } return []; } function buildElementRoleList(elementRolesMap) { function makeElementSelector(_ref) { let { name, attributes } = _ref; return "" + name + attributes.map(_ref2 => { let { name: attributeName, value, constraints = [] } = _ref2; const shouldNotExist = constraints.indexOf('undefined') !== -1; if (shouldNotExist) { return ":not([" + attributeName + "])"; } else if (value) { return "[" + attributeName + "=\"" + value + "\"]"; } else { return "[" + attributeName + "]"; } }).join(''); } function getSelectorSpecificity(_ref3) { let { attributes = [] } = _ref3; return attributes.length; } function bySelectorSpecificity(_ref4, _ref5) { let { specificity: leftSpecificity } = _ref4; let { specificity: rightSpecificity } = _ref5; return rightSpecificity - leftSpecificity; } function match(element) { let { attributes = [] } = element; // https://github.com/testing-library/dom-testing-library/issues/814 const typeTextIndex = attributes.findIndex(attribute => attribute.value && attribute.name === 'type' && attribute.value === 'text'); if (typeTextIndex >= 0) { // not using splice to not mutate the attributes array attributes = [...attributes.slice(0, typeTextIndex), ...attributes.slice(typeTextIndex + 1)]; } const selector = makeElementSelector({ ...element, attributes }); return node => { if (typeTextIndex >= 0 && node.type !== 'text') { return false; } return node.matches(selector); }; } let result = []; // eslint bug here: // eslint-disable-next-line no-unused-vars for (const [element, roles] of elementRolesMap.entries()) { result = [...result, { match: match(element), roles: Array.from(roles), specificity: getSelectorSpecificity(element) }]; } return result.sort(bySelectorSpecificity); } function getRoles(container, _temp) { let { hidden = false } = _temp === void 0 ? {} : _temp; function flattenDOM(node) { return [node, ...Array.from(node.children).reduce((acc, child) => [...acc, ...flattenDOM(child)], [])]; } return flattenDOM(container).filter(element => { return hidden === false ? isInaccessible(element) === false : true; }).reduce((acc, node) => { let roles = []; // TODO: This violates html-aria which does not allow any role on every element if (node.hasAttribute('role')) { roles = node.getAttribute('role').split(' ').slice(0, 1); } else { roles = getImplicitAriaRoles(node); } return roles.reduce((rolesAcc, role) => Array.isArray(rolesAcc[role]) ? { ...rolesAcc, [role]: [...rolesAcc[role], node] } : { ...rolesAcc, [role]: [node] }, acc); }, {}); } function prettyRoles(dom, _ref6) { let { hidden, includeDescription } = _ref6; const roles = getRoles(dom, { hidden }); // We prefer to skip generic role, we don't recommend it return Object.entries(roles).filter(_ref7 => { let [role] = _ref7; return role !== 'generic'; }).map(_ref8 => { let [role, elements] = _ref8; const delimiterBar = '-'.repeat(50); const elementsString = elements.map(el => { const nameString = "Name \"" + computeAccessibleName(el, { computedStyleSupportsPseudoElements: getConfig().computedStyleSupportsPseudoElements }) + "\":\n"; const domString = prettyDOM(el.cloneNode(false)); if (includeDescription) { const descriptionString = "Description \"" + computeAccessibleDescription(el, { computedStyleSupportsPseudoElements: getConfig().computedStyleSupportsPseudoElements }) + "\":\n"; return "" + nameString + descriptionString + domString; } return "" + nameString + domString; }).join('\n\n'); return role + ":\n\n" + elementsString + "\n\n" + delimiterBar; }).join('\n'); } const logRoles = function (dom, _temp2) { let { hidden = false } = _temp2 === void 0 ? {} : _temp2; return console.log(prettyRoles(dom, { hidden })); }; /** * @param {Element} element - * @returns {boolean | undefined} - false/true if (not)selected, undefined if not selectable */ function computeAriaSelected(element) { // implicit value from html-aam mappings: https://www.w3.org/TR/html-aam-1.0/#html-attribute-state-and-property-mappings // https://www.w3.org/TR/html-aam-1.0/#details-id-97 if (element.tagName === 'OPTION') { return element.selected; } // explicit value return checkBooleanAttribute(element, 'aria-selected'); } /** * @param {Element} element - * @returns {boolean | undefined} - false/true if (not)checked, undefined if not checked-able */ function computeAriaChecked(element) { // implicit value from html-aam mappings: https://www.w3.org/TR/html-aam-1.0/#html-attribute-state-and-property-mappings // https://www.w3.org/TR/html-aam-1.0/#details-id-56 // https://www.w3.org/TR/html-aam-1.0/#details-id-67 if ('indeterminate' in element && element.indeterminate) { return undefined; } if ('checked' in element) { return element.checked; } // explicit value return checkBooleanAttribute(element, 'aria-checked'); } /** * @param {Element} element - * @returns {boolean | undefined} - false/true if (not)pressed, undefined if not press-able */ function computeAriaPressed(element) { // https://www.w3.org/TR/wai-aria-1.1/#aria-pressed return checkBooleanAttribute(element, 'aria-pressed'); } /** * @param {Element} element - * @returns {boolean | string | null} - */ function computeAriaCurrent(element) { var _ref9, _checkBooleanAttribut; // https://www.w3.org/TR/wai-aria-1.1/#aria-current return (_ref9 = (_checkBooleanAttribut = checkBooleanAttribute(element, 'aria-current')) != null ? _checkBooleanAttribut : element.getAttribute('aria-current')) != null ? _ref9 : false; } /** * @param {Element} element - * @returns {boolean | undefined} - false/true if (not)expanded, undefined if not expand-able */ function computeAriaExpanded(element) { // https://www.w3.org/TR/wai-aria-1.1/#aria-expanded return checkBooleanAttribute(element, 'aria-expanded'); } function checkBooleanAttribute(element, attribute) { const attributeValue = element.getAttribute(attribute); if (attributeValue === 'true') { return true; } if (attributeValue === 'false') { return false; } return undefined; } /** * @param {Element} element - * @returns {number | undefined} - number if implicit heading or aria-level present, otherwise undefined */ function computeHeadingLevel(element) { // https://w3c.github.io/html-aam/#el-h1-h6 // https://w3c.github.io/html-aam/#el-h1-h6 const implicitHeadingLevels = { H1: 1, H2: 2, H3: 3, H4: 4, H5: 5, H6: 6 }; // explicit aria-level value // https://www.w3.org/TR/wai-aria-1.2/#aria-level const ariaLevelAttribute = element.getAttribute('aria-level') && Number(element.getAttribute('aria-level')); return ariaLevelAttribute || implicitHeadingLevels[element.tagName]; } const normalize = getDefaultNormalizer(); function escapeRegExp(string) { return string.replace(/[.*+\-?^${}()|[\]\\]/g, '\\$&'); // $& means the whole matched string } function getRegExpMatcher(string) { return new RegExp(escapeRegExp(string.toLowerCase()), 'i'); } function makeSuggestion(queryName, element, content, _ref) { let { variant, name } = _ref; let warning = ''; const queryOptions = {}; const queryArgs = [['Role', 'TestId'].includes(queryName) ? content : getRegExpMatcher(content)]; if (name) { queryOptions.name = getRegExpMatcher(name); } if (queryName === 'Role' && isInaccessible(element)) { queryOptions.hidden = true; warning = "Element is inaccessible. This means that the element and all its children are invisible to screen readers.\n If you are using the aria-hidden prop, make sure this is the right choice for your case.\n "; } if (Object.keys(queryOptions).length > 0) { queryArgs.push(queryOptions); } const queryMethod = variant + "By" + queryName; return { queryName, queryMethod, queryArgs, variant, warning, toString() { if (warning) { console.warn(warning); } let [text, options] = queryArgs; text = typeof text === 'string' ? "'" + text + "'" : text; options = options ? ", { " + Object.entries(options).map(_ref2 => { let [k, v] = _ref2; return k + ": " + v; }).join(', ') + " }" : ''; return queryMethod + "(" + text + options + ")"; } }; } function canSuggest(currentMethod, requestedMethod, data) { return data && (!requestedMethod || requestedMethod.toLowerCase() === currentMethod.toLowerCase()); } function getSuggestedQuery(element, variant, method) { var _element$getAttribute, _getImplicitAriaRoles; if (variant === void 0) { variant = 'get'; } // don't create suggestions for script and style elements if (element.matches(getConfig().defaultIgnore)) { return undefined; } //We prefer to suggest something else if the role is generic const role = (_element$getAttribute = element.getAttribute('role')) != null ? _element$getAttribute : (_getImplicitAriaRoles = getImplicitAriaRoles(element)) == null ? void 0 : _getImplicitAriaRoles[0]; if (role !== 'generic' && canSuggest('Role', method, role)) { return makeSuggestion('Role', element, role, { variant, name: computeAccessibleName(element, { computedStyleSupportsPseudoElements: getConfig().computedStyleSupportsPseudoElements }) }); } const labelText = getLabels$1(document, element).map(label => label.content).join(' '); if (canSuggest('LabelText', method, labelText)) { return makeSuggestion('LabelText', element, labelText, { variant }); } const placeholderText = element.getAttribute('placeholder'); if (canSuggest('PlaceholderText', method, placeholderText)) { return makeSuggestion('PlaceholderText', element, placeholderText, { variant }); } const textContent = normalize(getNodeText(element)); if (canSuggest('Text', method, textContent)) { return makeSuggestion('Text', element, textContent, { variant }); } if (canSuggest('DisplayValue', method, element.value)) { return makeSuggestion('DisplayValue', element, normalize(element.value), { variant }); } const alt = element.getAttribute('alt'); if (canSuggest('AltText', method, alt)) { return makeSuggestion('AltText', element, alt, { variant }); } const title = element.getAttribute('title'); if (canSuggest('Title', method, title)) { return makeSuggestion('Title', element, title, { variant }); } const testId = element.getAttribute(getConfig().testIdAttribute); if (canSuggest('TestId', method, testId)) { return makeSuggestion('TestId', element, testId, { variant }); } return undefined; } // closer to their code (because async stack traces are hard to follow). function copyStackTrace(target, source) { target.stack = source.stack.replace(source.message, target.message); } function waitFor(callback, _ref) { let { container = getDocument(), timeout = getConfig().asyncUtilTimeout, showOriginalStackTrace = getConfig().showOriginalStackTrace, stackTraceError, interval = 50, onTimeout = error => { error.message = getConfig().getElementError(error.message, container).message; return error; }, mutationObserverOptions = { subtree: true, childList: true, attributes: true, characterData: true } } = _ref; if (typeof callback !== 'function') { throw new TypeError('Received `callback` arg must be a function'); } return new Promise(async (resolve, reject) => { let lastError, intervalId, observer; let finished = false; let promiseStatus = 'idle'; const overallTimeoutTimer = setTimeout(handleTimeout, timeout); const usingJestFakeTimers = jestFakeTimersAreEnabled(); if (usingJestFakeTimers) { const { unstable_advanceTimersWrapper: advanceTimersWrapper } = getConfig(); checkCallback(); // this is a dangerous rule to disable because it could lead to an // infinite loop. However, eslint isn't smart enough to know that we're // setting finished inside `onDone` which will be called when we're done // waiting or when we've timed out. // eslint-disable-next-line no-unmodified-loop-condition while (!finished) { if (!jestFakeTimersAreEnabled()) { const error = new Error("Changed from using fake timers to real timers while using waitFor. This is not allowed and will result in very strange behavior. Please ensure you're awaiting all async things your test is doing before changing to real timers. For more info, please go to https://github.com/testing-library/dom-testing-library/issues/830"); if (!showOriginalStackTrace) copyStackTrace(error, stackTraceError); reject(error); return; } // we *could* (maybe should?) use `advanceTimersToNextTimer` but it's // possible that could make this loop go on forever if someone is using // third party code that's setting up recursive timers so rapidly that // the user's timer's don't get a chance to resolve. So we'll advance // by an interval instead. (We have a test for this case). advanceTimersWrapper(() => { jest.advanceTimersByTime(interval); }); // It's really important that checkCallback is run *before* we flush // in-flight promises. To be honest, I'm not sure why, and I can't quite // think of a way to reproduce the problem in a test, but I spent // an entire day banging my head against a wall on this. checkCallback(); if (finished) { break; } // In this rare case, we *need* to wait for in-flight promises // to resolve before continuing. We don't need to take advantage // of parallelization so we're fine. // https://stackoverflow.com/a/59243586/971592 // eslint-disable-next-line no-await-in-loop await advanceTimersWrapper(async () => { await new Promise(r => { setTimeout(r, 0); jest.advanceTimersByTime(0); }); }); } } else { try { checkContainerType(container); } catch (e) { reject(e); return; } intervalId = setInterval(checkRealTimersCallback, interval); const { MutationObserver } = getWindowFromNode(container); observer = new MutationObserver(checkRealTimersCallback); observer.observe(container, mutationObserverOptions); checkCallback(); } function onDone(error, result) { finished = true; clearTimeout(overallTimeoutTimer); if (!usingJestFakeTimers) { clearInterval(intervalId); observer.disconnect(); } if (error) { reject(error); } else { resolve(result); } } function checkRealTimersCallback() { if (jestFakeTimersAreEnabled()) { const error = new Error("Changed from using real timers to fake timers while using waitFor. This is not allowed and will result in very strange behavior. Please ensure you're awaiting all async things your test is doing before changing to fake timers. For more info, please go to https://github.com/testing-library/dom-testing-library/issues/830"); if (!showOriginalStackTrace) copyStackTrace(error, stackTraceError); return reject(error); } else { return checkCallback(); } } function checkCallback() { if (promiseStatus === 'pending') return; try { const result = runWithExpensiveErrorDiagnosticsDisabled(callback); if (typeof (result == null ? void 0 : result.then) === 'function') { promiseStatus = 'pending'; result.then(resolvedValue => { promiseStatus = 'resolved'; onDone(null, resolvedValue); }, rejectedValue => { promiseStatus = 'rejected'; lastError = rejectedValue; }); } else { onDone(null, result); } // If `callback` throws, wait for the next mutation, interval, or timeout. } catch (error) { // Save the most recent callback error to reject the promise with it in the event of a timeout lastError = error; } } function handleTimeout() { let error; if (lastError) { error = lastError; if (!showOriginalStackTrace && error.name === 'TestingLibraryElementError') { copyStackTrace(error, stackTraceError); } } else { error = new Error('Timed out in waitFor.'); if (!showOriginalStackTrace) { copyStackTrace(error, stackTraceError); } } onDone(onTimeout(error), null); } }); } function waitForWrapper(callback, options) { // create the error here so its stack trace is as close to the // calling code as possible const stackTraceError = new Error('STACK_TRACE_MESSAGE'); return getConfig().asyncWrapper(() => waitFor(callback, { stackTraceError, ...options })); } /* eslint max-lines-per-function: ["error", {"max": 200}], */ function getElementError(message, container) { return getConfig().getElementError(message, container); } function getMultipleElementsFoundError(message, container) { return getElementError(message + "\n\n(If this is intentional, then use the `*AllBy*` variant of the query (like `queryAllByText`, `getAllByText`, or `findAllByText`)).", container); } function queryAllByAttribute(attribute, container, text, _temp) { let { exact = true, collapseWhitespace, trim, normalizer } = _temp === void 0 ? {} : _temp; const matcher = exact ? matches : fuzzyMatches; const matchNormalizer = makeNormalizer({ collapseWhitespace, trim, normalizer }); return Array.from(container.querySelectorAll("[" + attribute + "]")).filter(node => matcher(node.getAttribute(attribute), node, text, matchNormalizer)); } function queryByAttribute(attribute, container, text, options) { const els = queryAllByAttribute(attribute, container, text, options); if (els.length > 1) { throw getMultipleElementsFoundError("Found multiple elements by [" + attribute + "=" + text + "]", container); } return els[0] || null; } // this accepts a query function and returns a function which throws an error // if more than one elements is returned, otherwise it returns the first // element or null function makeSingleQuery(allQuery, getMultipleError) { return function (container) { for (var _len = arguments.length, args = new Array(_len > 1 ? _len - 1 : 0), _key = 1; _key < _len; _key++) { args[_key - 1] = arguments[_key]; } const els = allQuery(container, ...args); if (els.length > 1) { const elementStrings = els.map(element => getElementError(null, element).message).join('\n\n'); throw getMultipleElementsFoundError(getMultipleError(container, ...args) + "\n\nHere are the matching elements:\n\n" + elementStrings, container); } return els[0] || null; }; } function getSuggestionError(suggestion, container) { return getConfig().getElementError("A better query is available, try this:\n" + suggestion.toString() + "\n", container); } // this accepts a query function and returns a function which throws an error // if an empty list of elements is returned function makeGetAllQuery(allQuery, getMissingError) { return function (container) { for (var _len2 = arguments.length, args = new Array(_len2 > 1 ? _len2 - 1 : 0), _key2 = 1; _key2 < _len2; _key2++) { args[_key2 - 1] = arguments[_key2]; } const els = allQuery(container, ...args); if (!els.length) { throw getConfig().getElementError(getMissingError(container, ...args), container); } return els; }; } // this accepts a getter query function and returns a function which calls // waitFor and passing a function which invokes the getter. function makeFindQuery(getter) { return (container, text, options, waitForOptions) => { return waitForWrapper(() => { return getter(container, text, options); }, { container, ...waitForOptions }); }; } const wrapSingleQueryWithSuggestion = (query, queryAllByName, variant) => function (container) { for (var _len3 = arguments.length, args = new Array(_len3 > 1 ? _len3 - 1 : 0), _key3 = 1; _key3 < _len3; _key3++) { args[_key3 - 1] = arguments[_key3]; } const element = query(container, ...args); const [{ suggest = getConfig().throwSuggestions } = {}] = args.slice(-1); if (element && suggest) { const suggestion = getSuggestedQuery(element, variant); if (suggestion && !queryAllByName.endsWith(suggestion.queryName)) { throw getSuggestionError(suggestion.toString(), container); } } return element; }; const wrapAllByQueryWithSuggestion = (query, queryAllByName, variant) => function (container) { for (var _len4 = arguments.length, args = new Array(_len4 > 1 ? _len4 - 1 : 0), _key4 = 1; _key4 < _len4; _key4++) { args[_key4 - 1] = arguments[_key4]; } const els = query(container, ...args); const [{ suggest = getConfig().throwSuggestions } = {}] = args.slice(-1); if (els.length && suggest) { // get a unique list of all suggestion messages. We are only going to make a suggestion if // all the suggestions are the same const uniqueSuggestionMessages = [...new Set(els.map(element => { var _getSuggestedQuery; return (_getSuggestedQuery = getSuggestedQuery(element, variant)) == null ? void 0 : _getSuggestedQuery.toString(); }))]; if ( // only want to suggest if all the els have the same suggestion. uniqueSuggestionMessages.length === 1 && !queryAllByName.endsWith( // eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- TODO: Can this be null at runtime? getSuggestedQuery(els[0], variant).queryName)) { throw getSuggestionError(uniqueSuggestionMessages[0], container); } } return els; }; // TODO: This deviates from the published declarations // However, the implementation always required a dyadic (after `container`) not variadic `queryAllBy` considering the implementation of `makeFindQuery` // This is at least statically true and can be verified by accepting `QueryMethod` function buildQueries(queryAllBy, getMultipleError, getMissingError) { const queryBy = wrapSingleQueryWithSuggestion(makeSingleQuery(queryAllBy, getMultipleError), queryAllBy.name, 'query'); const getAllBy = makeGetAllQuery(queryAllBy, getMissingError); const getBy = makeSingleQuery(getAllBy, getMultipleError); const getByWithSuggestions = wrapSingleQueryWithSuggestion(getBy, queryAllBy.name, 'get'); const getAllWithSuggestions = wrapAllByQueryWithSuggestion(getAllBy, queryAllBy.name.replace('query', 'get'), 'getAll'); const findAllBy = makeFindQuery(wrapAllByQueryWithSuggestion(getAllBy, queryAllBy.name, 'findAll')); const findBy = makeFindQuery(wrapSingleQueryWithSuggestion(getBy, queryAllBy.name, 'find')); return [queryBy, getAllWithSuggestions, getByWithSuggestions, findAllBy, findBy]; } var queryHelpers = /*#__PURE__*/Object.freeze({ __proto__: null, getElementError: getElementError, wrapAllByQueryWithSuggestion: wrapAllByQueryWithSuggestion, wrapSingleQueryWithSuggestion: wrapSingleQueryWithSuggestion, getMultipleElementsFoundError: getMultipleElementsFoundError, queryAllByAttribute: queryAllByAttribute, queryByAttribute: queryByAttribute, makeSingleQuery: makeSingleQuery, makeGetAllQuery: makeGetAllQuery, makeFindQuery: makeFindQuery, buildQueries: buildQueries }); function queryAllLabels(container) { return Array.from(container.querySelectorAll('label,input')).map(node => { return { node, textToMatch: getLabelContent(node) }; }).filter(_ref => { let { textToMatch } = _ref; return textToMatch !== null; }); } const queryAllLabelsByText = function (container, text, _temp) { let { exact = true, trim, collapseWhitespace, normalizer } = _temp === void 0 ? {} : _temp; const matcher = exact ? matches : fuzzyMatches; const matchNormalizer = makeNormalizer({ collapseWhitespace, trim, normalizer }); const textToMatchByLabels = queryAllLabels(container); return textToMatchByLabels.filter(_ref2 => { let { node, textToMatch } = _ref2; return matcher(textToMatch, node, text, matchNormalizer); }).map(_ref3 => { let { node } = _ref3; return node; }); }; const queryAllByLabelText = function (container, text, _temp2) { let { selector = '*', exact = true, collapseWhitespace, trim, normalizer } = _temp2 === void 0 ? {} : _temp2; checkContainerType(container); const matcher = exact ? matches : fuzzyMatches; const matchNormalizer = makeNormalizer({ collapseWhitespace, trim, normalizer }); const matchingLabelledElements = Array.from(container.querySelectorAll('*')).filter(element => { return getRealLabels(element).length || element.hasAttribute('aria-labelledby'); }).reduce((labelledElements, labelledElement) => { const labelList = getLabels$1(container, labelledElement, { selector }); labelList.filter(label => Boolean(label.formControl)).forEach(label => { if (matcher(label.content, label.formControl, text, matchNormalizer) && label.formControl) labelledElements.push(label.formControl); }); const labelsValue = labelList.filter(label => Boolean(label.content)).map(label => label.content); if (matcher(labelsValue.join(' '), labelledElement, text, matchNormalizer)) labelledElements.push(labelledElement); if (labelsValue.length > 1) { labelsValue.forEach((labelValue, index) => { if (matcher(labelValue, labelledElement, text, matchNormalizer)) labelledElements.push(labelledElement); const labelsFiltered = [...labelsValue]; labelsFiltered.splice(index, 1); if (labelsFiltered.length > 1) { if (matcher(labelsFiltered.join(' '), labelledElement, text, matchNormalizer)) labelledElements.push(labelledElement); } }); } return labelledElements; }, []).concat(queryAllByAttribute('aria-label', container, text, { exact, normalizer: matchNormalizer })); return Array.from(new Set(matchingLabelledElements)).filter(element => element.matches(selector)); }; // the getAll* query would normally look like this: // const getAllByLabelText = makeGetAllQuery( // queryAllByLabelText, // (c, text) => `Unable to find a label with the text of: ${text}`, // ) // however, we can give a more helpful error message than the generic one, // so we're writing this one out by hand. const getAllByLabelText = function (container, text) { for (var _len = arguments.length, rest = new Array(_len > 2 ? _len - 2 : 0), _key = 2; _key < _len; _key++) { rest[_key - 2] = arguments[_key]; } const els = queryAllByLabelText(container, text, ...rest); if (!els.length) { const labels = queryAllLabelsByText(container, text, ...rest); if (labels.length) { const tagNames = labels.map(label => getTagNameOfElementAssociatedWithLabelViaFor(container, label)).filter(tagName => !!tagName); if (tagNames.length) { throw getConfig().getElementError(tagNames.map(tagName => "Found a label with the text of: " + text + ", however the element associated with this label (<" + tagName + " />) is non-labellable [https://html.spec.whatwg.org/multipage/forms.html#category-label]. If you really need to label a <" + tagName + " />, you can use aria-label or aria-labelledby instead.").join('\n\n'), container); } else { throw getConfig().getElementError("Found a label with the text of: " + text + ", however no form control was found associated to that label. Make sure you're using the \"for\" attribute or \"aria-labelledby\" attribute correctly.", container); } } else { throw getConfig().getElementError("Unable to find a label with the text of: " + text, container); } } return els; }; function getTagNameOfElementAssociatedWithLabelViaFor(container, label) { const htmlFor = label.getAttribute('for'); if (!htmlFor) { return null; } const element = container.querySelector("[id=\"" + htmlFor + "\"]"); return element ? element.tagName.toLowerCase() : null; } // the reason mentioned above is the same reason we're not using buildQueries const getMultipleError$7 = (c, text) => "Found multiple elements with the text of: " + text; const queryByLabelText = wrapSingleQueryWithSuggestion(makeSingleQuery(queryAllByLabelText, getMultipleError$7), queryAllByLabelText.name, 'query'); const getByLabelText = makeSingleQuery(getAllByLabelText, getMultipleError$7); const findAllByLabelText = makeFindQuery(wrapAllByQueryWithSuggestion(getAllByLabelText, getAllByLabelText.name, 'findAll')); const findByLabelText = makeFindQuery(wrapSingleQueryWithSuggestion(getByLabelText, getAllByLabelText.name, 'find')); const getAllByLabelTextWithSuggestions = wrapAllByQueryWithSuggestion(getAllByLabelText, getAllByLabelText.name, 'getAll'); const getByLabelTextWithSuggestions = wrapSingleQueryWithSuggestion(getByLabelText, getAllByLabelText.name, 'get'); const queryAllByLabelTextWithSuggestions = wrapAllByQueryWithSuggestion(queryAllByLabelText, queryAllByLabelText.name, 'queryAll'); const queryAllByPlaceholderText = function () { for (var _len = arguments.length, args = new Array(_len), _key = 0; _key < _len; _key++) { args[_key] = arguments[_key]; } checkContainerType(args[0]); return queryAllByAttribute('placeholder', ...args); }; const getMultipleError$6 = (c, text) => "Found multiple elements with the placeholder text of: " + text; const getMissingError$6 = (c, text) => "Unable to find an element with the placeholder text of: " + text; const queryAllByPlaceholderTextWithSuggestions = wrapAllByQueryWithSuggestion(queryAllByPlaceholderText, queryAllByPlaceholderText.name, 'queryAll'); const [queryByPlaceholderText, getAllByPlaceholderText, getByPlaceholderText, findAllByPlaceholderText, findByPlaceholderText] = buildQueries(queryAllByPlaceholderText, getMultipleError$6, getMissingError$6); const queryAllByText = function (container, text, _temp) { let { selector = '*', exact = true, collapseWhitespace, trim, ignore = getConfig().defaultIgnore, normalizer } = _temp === void 0 ? {} : _temp; checkContainerType(container); const matcher = exact ? matches : fuzzyMatches; const matchNormalizer = makeNormalizer({ collapseWhitespace, trim, normalizer }); let baseArray = []; if (typeof container.matches === 'function' && container.matches(selector)) { baseArray = [container]; } return [...baseArray, ...Array.from(container.querySelectorAll(selector))] // TODO: `matches` according lib.dom.d.ts can get only `string` but according our code it can handle also boolean :) .filter(node => !ignore || !node.matches(ignore)).filter(node => matcher(getNodeText(node), node, text, matchNormalizer)); }; const getMultipleError$5 = (c, text) => "Found multiple elements with the text: " + text; const getMissingError$5 = function (c, text, options) { if (options === void 0) { options = {}; } const { collapseWhitespace, trim, normalizer } = options; const matchNormalizer = makeNormalizer({ collapseWhitespace, trim, normalizer }); const normalizedText = matchNormalizer(text.toString()); const isNormalizedDifferent = normalizedText !== text.toString(); return "Unable to find an element with the text: " + (isNormalizedDifferent ? normalizedText + " (normalized from '" + text + "')" : text) + ". This could be because the text is broken up by multiple elements. In this case, you can provide a function for your text matcher to make your matcher more flexible."; }; const queryAllByTextWithSuggestions = wrapAllByQueryWithSuggestion(queryAllByText, queryAllByText.name, 'queryAll'); const [queryByText, getAllByText, getByText, findAllByText, findByText] = buildQueries(queryAllByText, getMultipleError$5, getMissingError$5); const queryAllByDisplayValue = function (container, value, _temp) { let { exact = true, collapseWhitespace, trim, normalizer } = _temp === void 0 ? {} : _temp; checkContainerType(container); const matcher = exact ? matches : fuzzyMatches; const matchNormalizer = makeNormalizer({ collapseWhitespace, trim, normalizer }); return Array.from(container.querySelectorAll("input,textarea,select")).filter(node => { if (node.tagName === 'SELECT') { const selectedOptions = Array.from(node.options).filter(option => option.selected); return selectedOptions.some(optionNode => matcher(getNodeText(optionNode), optionNode, value, matchNormalizer)); } else { return matcher(node.value, node, value, matchNormalizer); } }); }; const getMultipleError$4 = (c, value) => "Found multiple elements with the display value: " + value + "."; const getMissingError$4 = (c, value) => "Unable to find an element with the display value: " + value + "."; const queryAllByDisplayValueWithSuggestions = wrapAllByQueryWithSuggestion(queryAllByDisplayValue, queryAllByDisplayValue.name, 'queryAll'); const [queryByDisplayValue, getAllByDisplayValue, getByDisplayValue, findAllByDisplayValue, findByDisplayValue] = buildQueries(queryAllByDisplayValue, getMultipleError$4, getMissingError$4); const VALID_TAG_REGEXP = /^(img|input|area|.+-.+)$/i; const queryAllByAltText = function (container, alt, options) { if (options === void 0) { options = {}; } checkContainerType(container); return queryAllByAttribute('alt', container, alt, options).filter(node => VALID_TAG_REGEXP.test(node.tagName)); }; const getMultipleError$3 = (c, alt) => "Found multiple elements with the alt text: " + alt; const getMissingError$3 = (c, alt) => "Unable to find an element with the alt text: " + alt; const queryAllByAltTextWithSuggestions = wrapAllByQueryWithSuggestion(queryAllByAltText, queryAllByAltText.name, 'queryAll'); const [queryByAltText, getAllByAltText, getByAltText, findAllByAltText, findByAltText] = buildQueries(queryAllByAltText, getMultipleError$3, getMissingError$3); const isSvgTitle = node => { var _node$parentElement; return node.tagName.toLowerCase() === 'title' && ((_node$parentElement = node.parentElement) == null ? void 0 : _node$parentElement.tagName.toLowerCase()) === 'svg'; }; const queryAllByTitle = function (container, text, _temp) { let { exact = true, collapseWhitespace, trim, normalizer } = _temp === void 0 ? {} : _temp; checkContainerType(container); const matcher = exact ? matches : fuzzyMatches; const matchNormalizer = makeNormalizer({ collapseWhitespace, trim, normalizer }); return Array.from(container.querySelectorAll('[title], svg > title')).filter(node => matcher(node.getAttribute('title'), node, text, matchNormalizer) || isSvgTitle(node) && matcher(getNodeText(node), node, text, matchNormalizer)); }; const getMultipleError$2 = (c, title) => "Found multiple elements with the title: " + title + "."; const getMissingError$2 = (c, title) => "Unable to find an element with the title: " + title + "."; const queryAllByTitleWithSuggestions = wrapAllByQueryWithSuggestion(queryAllByTitle, queryAllByTitle.name, 'queryAll'); const [queryByTitle, getAllByTitle, getByTitle, findAllByTitle, findByTitle] = buildQueries(queryAllByTitle, getMultipleError$2, getMissingError$2); function queryAllByRole(container, role, _temp) { let { exact = true, collapseWhitespace, hidden = getConfig().defaultHidden, name, description, trim, normalizer, queryFallbacks = false, selected, checked, pressed, current, level, expanded } = _temp === void 0 ? {} : _temp; checkContainerType(container); const matcher = exact ? matches : fuzzyMatches; const matchNormalizer = makeNormalizer({ collapseWhitespace, trim, normalizer }); if (selected !== undefined) { var _allRoles$get; // guard against unknown roles if (((_allRoles$get = roles_1.get(role)) == null ? void 0 : _allRoles$get.props['aria-selected']) === undefined) { throw new Error("\"aria-selected\" is not supported on role \"" + role + "\"."); } } if (checked !== undefined) { var _allRoles$get2; // guard against unknown roles if (((_allRoles$get2 = roles_1.get(role)) == null ? void 0 : _allRoles$get2.props['aria-checked']) === undefined) { throw new Error("\"aria-checked\" is not supported on role \"" + role + "\"."); } } if (pressed !== undefined) { var _allRoles$get3; // guard against unknown roles if (((_allRoles$get3 = roles_1.get(role)) == null ? void 0 : _allRoles$get3.props['aria-pressed']) === undefined) { throw new Error("\"aria-pressed\" is not supported on role \"" + role + "\"."); } } if (current !== undefined) { var _allRoles$get4; /* istanbul ignore next */ // guard against unknown roles // All currently released ARIA versions support `aria-current` on all roles. // Leaving this for symetry and forward compatibility if (((_allRoles$get4 = roles_1.get(role)) == null ? void 0 : _allRoles$get4.props['aria-current']) === undefined) { throw new Error("\"aria-current\" is not supported on role \"" + role + "\"."); } } if (level !== undefined) { // guard against using `level` option with any role other than `heading` if (role !== 'heading') { throw new Error("Role \"" + role + "\" cannot have \"level\" property."); } } if (expanded !== undefined) { var _allRoles$get5; // guard against unknown roles if (((_allRoles$get5 = roles_1.get(role)) == null ? void 0 : _allRoles$get5.props['aria-expanded']) === undefined) { throw new Error("\"aria-expanded\" is not supported on role \"" + role + "\"."); } } const subtreeIsInaccessibleCache = new WeakMap(); function cachedIsSubtreeInaccessible(element) { if (!subtreeIsInaccessibleCache.has(element)) { subtreeIsInaccessibleCache.set(element, isSubtreeInaccessible(element)); } return subtreeIsInaccessibleCache.get(element); } return Array.from(container.querySelectorAll( // Only query elements that can be matched by the following filters makeRoleSelector(role, exact, normalizer ? matchNormalizer : undefined))).filter(node => { const isRoleSpecifiedExplicitly = node.hasAttribute('role'); if (isRoleSpecifiedExplicitly) { const roleValue = node.getAttribute('role'); if (queryFallbacks) { return roleValue.split(' ').filter(Boolean).some(text => matcher(text, node, role, matchNormalizer)); } // if a custom normalizer is passed then let normalizer handle the role value if (normalizer) { return matcher(roleValue, node, role, matchNormalizer); } // other wise only send the first word to match const [firstWord] = roleValue.split(' '); return matcher(firstWord, node, role, matchNormalizer); } const implicitRoles = getImplicitAriaRoles(node); return implicitRoles.some(implicitRole => matcher(implicitRole, node, role, matchNormalizer)); }).filter(element => { if (selected !== undefined) { return selected === computeAriaSelected(element); } if (checked !== undefined) { return checked === computeAriaChecked(element); } if (pressed !== undefined) { return pressed === computeAriaPressed(element); } if (current !== undefined) { return current === computeAriaCurrent(element); } if (expanded !== undefined) { return expanded === computeAriaExpanded(element); } if (level !== undefined) { return level === computeHeadingLevel(element); } // don't care if aria attributes are unspecified return true; }).filter(element => { if (name === undefined) { // Don't care return true; } return matches(computeAccessibleName(element, { computedStyleSupportsPseudoElements: getConfig().computedStyleSupportsPseudoElements }), element, name, text => text); }).filter(element => { if (description === undefined) { // Don't care return true; } return matches(computeAccessibleDescription(element, { computedStyleSupportsPseudoElements: getConfig().computedStyleSupportsPseudoElements }), element, description, text => text); }).filter(element => { return hidden === false ? isInaccessible(element, { isSubtreeInaccessible: cachedIsSubtreeInaccessible }) === false : true; }); } function makeRoleSelector(role, exact, customNormalizer) { var _roleElements$get; if (typeof role !== 'string') { // For non-string role parameters we can not determine the implicitRoleSelectors. return '*'; } const explicitRoleSelector = exact && !customNormalizer ? "*[role~=\"" + role + "\"]" : '*[role]'; const roleRelations = (_roleElements$get = roleElements_1.get(role)) != null ? _roleElements$get : new Set(); const implicitRoleSelectors = new Set(Array.from(roleRelations).map(_ref => { let { name } = _ref; return name; })); // Current transpilation config sometimes assumes `...` is always applied to arrays. // `...` is equivalent to `Array.prototype.concat` for arrays. // If you replace this code with `[explicitRoleSelector, ...implicitRoleSelectors]`, make sure every transpilation target retains the `...` in favor of `Array.prototype.concat`. return [explicitRoleSelector].concat(Array.from(implicitRoleSelectors)).join(','); } const getNameHint = name => { let nameHint = ''; if (name === undefined) { nameHint = ''; } else if (typeof name === 'string') { nameHint = " and name \"" + name + "\""; } else { nameHint = " and name `" + name + "`"; } return nameHint; }; const getMultipleError$1 = function (c, role, _temp2) { let { name } = _temp2 === void 0 ? {} : _temp2; return "Found multiple elements with the role \"" + role + "\"" + getNameHint(name); }; const getMissingError$1 = function (container, role, _temp3) { let { hidden = getConfig().defaultHidden, name, description } = _temp3 === void 0 ? {} : _temp3; if (getConfig()._disableExpensiveErrorDiagnostics) { return "Unable to find role=\"" + role + "\"" + getNameHint(name); } let roles = ''; Array.from(container.children).forEach(childElement => { roles += prettyRoles(childElement, { hidden, includeDescription: description !== undefined }); }); let roleMessage; if (roles.length === 0) { if (hidden === false) { roleMessage = 'There are no accessible roles. But there might be some inaccessible roles. ' + 'If you wish to access them, then set the `hidden` option to `true`. ' + 'Learn more about this here: https://testing-library.com/docs/dom-testing-library/api-queries#byrole'; } else { roleMessage = 'There are no available roles.'; } } else { roleMessage = ("\nHere are the " + (hidden === false ? 'accessible' : 'available') + " roles:\n\n " + roles.replace(/\n/g, '\n ').replace(/\n\s\s\n/g, '\n\n') + "\n").trim(); } let nameHint = ''; if (name === undefined) { nameHint = ''; } else if (typeof name === 'string') { nameHint = " and name \"" + name + "\""; } else { nameHint = " and name `" + name + "`"; } let descriptionHint = ''; if (description === undefined) { descriptionHint = ''; } else if (typeof description === 'string') { descriptionHint = " and description \"" + description + "\""; } else { descriptionHint = " and description `" + description + "`"; } return ("\nUnable to find an " + (hidden === false ? 'accessible ' : '') + "element with the role \"" + role + "\"" + nameHint + descriptionHint + "\n\n" + roleMessage).trim(); }; const queryAllByRoleWithSuggestions = wrapAllByQueryWithSuggestion(queryAllByRole, queryAllByRole.name, 'queryAll'); const [queryByRole, getAllByRole, getByRole, findAllByRole, findByRole] = buildQueries(queryAllByRole, getMultipleError$1, getMissingError$1); const getTestIdAttribute = () => getConfig().testIdAttribute; const queryAllByTestId = function () { for (var _len = arguments.length, args = new Array(_len), _key = 0; _key < _len; _key++) { args[_key] = arguments[_key]; } checkContainerType(args[0]); return queryAllByAttribute(getTestIdAttribute(), ...args); }; const getMultipleError = (c, id) => "Found multiple elements by: [" + getTestIdAttribute() + "=\"" + id + "\"]"; const getMissingError = (c, id) => "Unable to find an element by: [" + getTestIdAttribute() + "=\"" + id + "\"]"; const queryAllByTestIdWithSuggestions = wrapAllByQueryWithSuggestion(queryAllByTestId, queryAllByTestId.name, 'queryAll'); const [queryByTestId, getAllByTestId, getByTestId, findAllByTestId, findByTestId] = buildQueries(queryAllByTestId, getMultipleError, getMissingError); var queries = /*#__PURE__*/Object.freeze({ __proto__: null, queryAllByLabelText: queryAllByLabelTextWithSuggestions, queryByLabelText: queryByLabelText, getAllByLabelText: getAllByLabelTextWithSuggestions, getByLabelText: getByLabelTextWithSuggestions, findAllByLabelText: findAllByLabelText, findByLabelText: findByLabelText, queryByPlaceholderText: queryByPlaceholderText, queryAllByPlaceholderText: queryAllByPlaceholderTextWithSuggestions, getByPlaceholderText: getByPlaceholderText, getAllByPlaceholderText: getAllByPlaceholderText, findAllByPlaceholderText: findAllByPlaceholderText, findByPlaceholderText: findByPlaceholderText, queryByText: queryByText, queryAllByText: queryAllByTextWithSuggestions, getByText: getByText, getAllByText: getAllByText, findAllByText: findAllByText, findByText: findByText, queryByDisplayValue: queryByDisplayValue, queryAllByDisplayValue: queryAllByDisplayValueWithSuggestions, getByDisplayValue: getByDisplayValue, getAllByDisplayValue: getAllByDisplayValue, findAllByDisplayValue: findAllByDisplayValue, findByDisplayValue: findByDisplayValue, queryByAltText: queryByAltText, queryAllByAltText: queryAllByAltTextWithSuggestions, getByAltText: getByAltText, getAllByAltText: getAllByAltText, findAllByAltText: findAllByAltText, findByAltText: findByAltText, queryByTitle: queryByTitle, queryAllByTitle: queryAllByTitleWithSuggestions, getByTitle: getByTitle, getAllByTitle: getAllByTitle, findAllByTitle: findAllByTitle, findByTitle: findByTitle, queryByRole: queryByRole, queryAllByRole: queryAllByRoleWithSuggestions, getAllByRole: getAllByRole, getByRole: getByRole, findAllByRole: findAllByRole, findByRole: findByRole, queryByTestId: queryByTestId, queryAllByTestId: queryAllByTestIdWithSuggestions, getByTestId: getByTestId, getAllByTestId: getAllByTestId, findAllByTestId: findAllByTestId, findByTestId: findByTestId }); /** * @typedef {{[key: string]: Function}} FuncMap */ /** * @param {HTMLElement} element container * @param {FuncMap} queries object of functions * @param {Object} initialValue for reducer * @returns {FuncMap} returns object of functions bound to container */ function getQueriesForElement(element, queries$1, initialValue) { if (queries$1 === void 0) { queries$1 = queries; } if (initialValue === void 0) { initialValue = {}; } return Object.keys(queries$1).reduce((helpers, key) => { const fn = queries$1[key]; helpers[key] = fn.bind(null, element); return helpers; }, initialValue); } const isRemoved = result => !result || Array.isArray(result) && !result.length; // Check if the element is not present. // As the name implies, waitForElementToBeRemoved should check `present` --> `removed` function initialCheck(elements) { if (isRemoved(elements)) { throw new Error('The element(s) given to waitForElementToBeRemoved are already removed. waitForElementToBeRemoved requires that the element(s) exist(s) before waiting for removal.'); } } async function waitForElementToBeRemoved(callback, options) { // created here so we get a nice stacktrace const timeoutError = new Error('Timed out in waitForElementToBeRemoved.'); if (typeof callback !== 'function') { initialCheck(callback); const elements = Array.isArray(callback) ? callback : [callback]; const getRemainingElements = elements.map(element => { let parent = element.parentElement; if (parent === null) return () => null; while (parent.parentElement) parent = parent.parentElement; return () => parent.contains(element) ? element : null; }); callback = () => getRemainingElements.map(c => c()).filter(Boolean); } initialCheck(callback()); return waitForWrapper(() => { let result; try { result = callback(); } catch (error) { if (error.name === 'TestingLibraryElementError') { return undefined; } throw error; } if (!isRemoved(result)) { throw timeoutError; } return undefined; }, options); } /* eslint require-await: "off" */ const eventMap = { // Clipboard Events copy: { EventType: 'ClipboardEvent', defaultInit: { bubbles: true, cancelable: true, composed: true } }, cut: { EventType: 'ClipboardEvent', defaultInit: { bubbles: true, cancelable: true, composed: true } }, paste: { EventType: 'ClipboardEvent', defaultInit: { bubbles: true, cancelable: true, composed: true } }, // Composition Events compositionEnd: { EventType: 'CompositionEvent', defaultInit: { bubbles: true, cancelable: true, composed: true } }, compositionStart: { EventType: 'CompositionEvent', defaultInit: { bubbles: true, cancelable: true, composed: true } }, compositionUpdate: { EventType: 'CompositionEvent', defaultInit: { bubbles: true, cancelable: true, composed: true } }, // Keyboard Events keyDown: { EventType: 'KeyboardEvent', defaultInit: { bubbles: true, cancelable: true, charCode: 0, composed: true } }, keyPress: { EventType: 'KeyboardEvent', defaultInit: { bubbles: true, cancelable: true, charCode: 0, composed: true } }, keyUp: { EventType: 'KeyboardEvent', defaultInit: { bubbles: true, cancelable: true, charCode: 0, composed: true } }, // Focus Events focus: { EventType: 'FocusEvent', defaultInit: { bubbles: false, cancelable: false, composed: true } }, blur: { EventType: 'FocusEvent', defaultInit: { bubbles: false, cancelable: false, composed: true } }, focusIn: { EventType: 'FocusEvent', defaultInit: { bubbles: true, cancelable: false, composed: true } }, focusOut: { EventType: 'FocusEvent', defaultInit: { bubbles: true, cancelable: false, composed: true } }, // Form Events change: { EventType: 'Event', defaultInit: { bubbles: true, cancelable: false } }, input: { EventType: 'InputEvent', defaultInit: { bubbles: true, cancelable: false, composed: true } }, invalid: { EventType: 'Event', defaultInit: { bubbles: false, cancelable: true } }, submit: { EventType: 'Event', defaultInit: { bubbles: true, cancelable: true } }, reset: { EventType: 'Event', defaultInit: { bubbles: true, cancelable: true } }, // Mouse Events click: { EventType: 'MouseEvent', defaultInit: { bubbles: true, cancelable: true, button: 0, composed: true } }, contextMenu: { EventType: 'MouseEvent', defaultInit: { bubbles: true, cancelable: true, composed: true } }, dblClick: { EventType: 'MouseEvent', defaultInit: { bubbles: true, cancelable: true, composed: true } }, drag: { EventType: 'DragEvent', defaultInit: { bubbles: true, cancelable: true, composed: true } }, dragEnd: { EventType: 'DragEvent', defaultInit: { bubbles: true, cancelable: false, composed: true } }, dragEnter: { EventType: 'DragEvent', defaultInit: { bubbles: true, cancelable: true, composed: true } }, dragExit: { EventType: 'DragEvent', defaultInit: { bubbles: true, cancelable: false, composed: true } }, dragLeave: { EventType: 'DragEvent', defaultInit: { bubbles: true, cancelable: false, composed: true } }, dragOver: { EventType: 'DragEvent', defaultInit: { bubbles: true, cancelable: true, composed: true } }, dragStart: { EventType: 'DragEvent', defaultInit: { bubbles: true, cancelable: true, composed: true } }, drop: { EventType: 'DragEvent', defaultInit: { bubbles: true, cancelable: true, composed: true } }, mouseDown: { EventType: 'MouseEvent', defaultInit: { bubbles: true, cancelable: true, composed: true } }, mouseEnter: { EventType: 'MouseEvent', defaultInit: { bubbles: false, cancelable: false, composed: true } }, mouseLeave: { EventType: 'MouseEvent', defaultInit: { bubbles: false, cancelable: false, composed: true } }, mouseMove: { EventType: 'MouseEvent', defaultInit: { bubbles: true, cancelable: true, composed: true } }, mouseOut: { EventType: 'MouseEvent', defaultInit: { bubbles: true, cancelable: true, composed: true } }, mouseOver: { EventType: 'MouseEvent', defaultInit: { bubbles: true, cancelable: true, composed: true } }, mouseUp: { EventType: 'MouseEvent', defaultInit: { bubbles: true, cancelable: true, composed: true } }, // Selection Events select: { EventType: 'Event', defaultInit: { bubbles: true, cancelable: false } }, // Touch Events touchCancel: { EventType: 'TouchEvent', defaultInit: { bubbles: true, cancelable: false, composed: true } }, touchEnd: { EventType: 'TouchEvent', defaultInit: { bubbles: true, cancelable: true, composed: true } }, touchMove: { EventType: 'TouchEvent', defaultInit: { bubbles: true, cancelable: true, composed: true } }, touchStart: { EventType: 'TouchEvent', defaultInit: { bubbles: true, cancelable: true, composed: true } }, // UI Events resize: { EventType: 'UIEvent', defaultInit: { bubbles: false, cancelable: false } }, scroll: { EventType: 'UIEvent', defaultInit: { bubbles: false, cancelable: false } }, // Wheel Events wheel: { EventType: 'WheelEvent', defaultInit: { bubbles: true, cancelable: true, composed: true } }, // Media Events abort: { EventType: 'Event', defaultInit: { bubbles: false, cancelable: false } }, canPlay: { EventType: 'Event', defaultInit: { bubbles: false, cancelable: false } }, canPlayThrough: { EventType: 'Event', defaultInit: { bubbles: false, cancelable: false } }, durationChange: { EventType: 'Event', defaultInit: { bubbles: false, cancelable: false } }, emptied: { EventType: 'Event', defaultInit: { bubbles: false, cancelable: false } }, encrypted: { EventType: 'Event', defaultInit: { bubbles: false, cancelable: false } }, ended: { EventType: 'Event', defaultInit: { bubbles: false, cancelable: false } }, loadedData: { EventType: 'Event', defaultInit: { bubbles: false, cancelable: false } }, loadedMetadata: { EventType: 'Event', defaultInit: { bubbles: false, cancelable: false } }, loadStart: { EventType: 'ProgressEvent', defaultInit: { bubbles: false, cancelable: false } }, pause: { EventType: 'Event', defaultInit: { bubbles: false, cancelable: false } }, play: { EventType: 'Event', defaultInit: { bubbles: false, cancelable: false } }, playing: { EventType: 'Event', defaultInit: { bubbles: false, cancelable: false } }, progress: { EventType: 'ProgressEvent', defaultInit: { bubbles: false, cancelable: false } }, rateChange: { EventType: 'Event', defaultInit: { bubbles: false, cancelable: false } }, seeked: { EventType: 'Event', defaultInit: { bubbles: false, cancelable: false } }, seeking: { EventType: 'Event', defaultInit: { bubbles: false, cancelable: false } }, stalled: { EventType: 'Event', defaultInit: { bubbles: false, cancelable: false } }, suspend: { EventType: 'Event', defaultInit: { bubbles: false, cancelable: false } }, timeUpdate: { EventType: 'Event', defaultInit: { bubbles: false, cancelable: false } }, volumeChange: { EventType: 'Event', defaultInit: { bubbles: false, cancelable: false } }, waiting: { EventType: 'Event', defaultInit: { bubbles: false, cancelable: false } }, // Events load: { // TODO: load events can be UIEvent or Event depending on what generated them // This is were this abstraction breaks down. // But the common targets are ,