Vanilla-Components

A collection of lightweight, zero-dependency UI components and demos built with vanilla JavaScript — no frameworks required.

Components

src/vanilla-grid/

A high-performance virtualized data grid web component. Supports virtual scrolling for large datasets, multi-column sorting, column resizing/reordering/freezing/visibility, row selection, infinite scroll, a swappable DataManager pattern, and multiple built-in themes. Available as a plain VanillaGrid class or as the <vn-grid> custom element.

src/vanilla-grid-toolbar/

A state-aware toolbar web component (<vn-grid-toolbar>) bound to a vanilla-grid instance via a grid attribute/property. It observes the grid, exposes a structured state object to author-supplied templates, and forwards user actions (search, export, commands) back to the grid. It has no data of its own and cannot function standalone — it requires a linked grid.

src/vanilla-resize-box/

A resizable container web component (<vn-resize-box>) with a drag handle, configurable min/max constraints, and automatic CSS injection. The drag runs on Pointer Events (mouse, touch and pen on one path), the handle is a role="separator" grip announced to assistive technology but not focusable (resizing is pointer-only), and the whole geometry mirrors under dir="rtl".

Samples

Nine sample frontends demonstrate the components in different integration scenarios. Each folder is named <domain>-<framework>: the six *-js apps are plain HTML/CSS/JS, and the remaining three are framework ports of one of them — wikipedia-pages-vue (Vite + Vue 3) mirrors wikipedia-pages-js, github-repos-react (Vite + React) mirrors github-repos-js, and northwind-orders-angular mirrors northwind-orders-js. Each has its own README.

App Data source Highlights
people-cities-js MINT — Measurement INterface Translator, a BFF over OData Two tabs (People / Cities), server-side unit conversion per measurement environment, $metadata-driven headers and precision, EN/IT i18n, theming. Runs as-is — MINT is hosted at https://people-cities-app.mthome.org.
northwind-orders-js Public Northwind OData V4 Single tab, infinite scroll, server-side search, employee-photo decoding, order detail panel.
github-repos-js GitHub Search REST API Custom DataManager with Link-header (rel="next") continuation-URL pagination.
wikipedia-pages-js MediaWiki Action API Custom DataManager with opaque continue-token pagination, switchable category.
grid-minimal-js 1k–1M client-generated rows (selectable) Minimal, no-backend demo: StaticDataManager, mixed column types, multiple-row selection, inline theme and row-count selectors, theme-aware app canvas.
anilist-anime-js AniList GraphQL API GraphQLDataManager: page-based pagination with inline pageInfo.total, server-side sort/search/genre filters, EN/IT i18n, theming.
wikipedia-pages-vue MediaWiki Action API Vite + Vue 3 (TypeScript) port of wikipedia-pages-js; shows the components drop into a framework app.
github-repos-react GitHub Search REST API Vite + React 19 (TypeScript) port of github-repos-js; renders the custom elements directly from JSX with ref-handle wrappers instead of wrapper host elements or directives.
northwind-orders-angular Public Northwind OData V4 Angular (standalone, zoneless, signals) port of northwind-orders-js; built-in ODataDataManager from TypeScript, server-side search with persisted term, employee-photo pipeline, theme-driven expandable search.

Building

The project includes a Node.js build script with two modes: minify (default) and minify + obfuscate.

npm install              # install dependencies (first time only)
npm run build            # minify JS/CSS
npm run build:obfuscate  # minify + obfuscate the vanilla-* component JS (string encoding, control-flow
                          # flattening, dead code injection); sample-app JS is still only minified
npm run clean            # remove the dist folder

Output structure:

dist/
  index.html                  # landing page linking to every sample frontend + the docs site (generated)
  landing.css                  # landing page styles, copied from templates/landing/ (generated)
  docs/                        # browsable HTML rendering of docs/**/*.md + the READMEs (generated)
    index.html                 # docs index, grouped by component
    README.html                # rendered root README.md
    vanilla-grid/, vanilla-grid-toolbar/, vanilla-resize-box/
      README.html               # rendered component README.md
      *.html                    # rendered docs/<component>/*.md
    testing-and-coverage.html   # rendered docs/testing-and-coverage.md
    samples/<app>/
      README.html               # rendered samples/<app>/README.md — the landing card's "More" target
  vanilla-components/latest/  # standalone component packages (bundle + CSS + themes)
    vanilla-grid/
      vanilla-grid.bundle.js
      vanilla-grid.css
      themes/
    vanilla-grid-toolbar/
      vanilla-grid-toolbar.bundle.js
      vanilla-grid-toolbar.css
      themes/
    vanilla-resize-box/
      vanilla-resize-box.bundle.js
      vanilla-resize-box.css
  samples/
    <app>/                                     # one folder per plain-JS sample app
      index.html
      app.js
      styles.css
      modules/                                 # (grid-minimal-js has no modules/ dir)
      themes/
    wikipedia-pages-vue/, github-repos-react/      # Vite build output (Vue 3 / React)
      index.html
      assets/
      styles.css
      themes/
    northwind-orders-angular/                          # Angular CLI build output
      index.html
      main-*.js
      styles.css
      themes/

build.js also generates dist/index.html, a landing page that links to every built sample frontend plus the docs site (card labels derived from each app's <title>, framework badges derived from the app folder's <domain>-<framework> naming convention), so serving dist/ at a web root gives the base URL an index instead of an empty response. Each card carries two links and is not itself clickable: a More ↗ link trailing the description opens that app's README.md — rendered into the docs site at dist/docs/samples/<app>/README.html — and a Show ↗ button in the card's bottom-right corner opens the running app. Both open in a new tab. The page is built by filling templates/landing/index.html (a plain HTML/CSS skeleton with {{TOKEN}} placeholders, styled by templates/landing/landing.css) rather than generating markup inline in build.js, so the page can be edited and previewed like any static page.

build.js renders docs/**/*.md, the three component README.md files, the root README.md, and every sample app's README.md to static HTML under dist/docs/ (via marked, a build-only devDependency), so the design docs are browsable from a deploy without cloning the repo. Sample READMEs land under dist/docs/samples/<app>/ and are listed in the docs index under a samples group (nested ones, like people-cities-js's themes/README.md, are rendered too; READMEs under a sample's node_modules/, dist/, vendor/ or .angular/ folder are not). Markdown links between rendered pages are rewritten to the corresponding .html path (heading anchors use GitHub-style slugs, so existing #anchor links in the docs keep resolving); links into a plain-JS sample app are retargeted to the sibling dist/samples/ tree. Links into src/ that aren't one of the three component READMEs are degraded to plain text starting with src/... instead of a link, since the deployed repository isn't assumed to be publicly reachable; a link into a framework sample app's own source (wikipedia-pages-vue, github-repos-react, northwind-orders-angular — only their build output is deployed) is degraded the same way, to samples/... text. Every rendered page is wrapped in the shared shell templates/docs/page.html (styled by templates/docs/docs.css), which carries the same Vanilla Components favicon as the landing page, so a page opened from a card's More ↗ link shows the same tab icon.

The whole dist/ tree is deployed as one unit. dist/vanilla-components/latest/ is a fixed folder name — not stamped with the package version — so a deploy always exposes the latest build at a stable path; every dist/samples/<app>/ folder is a sibling tree of it, not a copy — each app's index.html has its component <script> paths rewritten from the source tree's ../../src/vanilla-grid/ (etc., relative to samples/<app>/) to ../../vanilla-components/latest/vanilla-grid/ (etc.), so no component files are duplicated per app. Deploy dist/ as-is; don't move a single app folder out on its own, or its component references will 404.

Hosts that want the multi-file (auto-loading) form of vanilla-grid instead of the single bundle can reference the source tree's per-file scripts directly (vanilla-grid.js + vanilla-grid-element.js, etc.) — that form isn't part of the dist/ build, which always ships the concatenated .bundle.js artifacts. The bundles disable their own auto-loader internally (window.VanillaGridSkipAutoload = true / window.VanillaGridToolbarSkipAutoload = true), so no extra fetches are issued.

Linting

npm run lint          # eslint .
npx eslint <file>     # lint a single file (subsumes node --check)

ESLint (flat config, eslint.config.js) runs two rule tiers: correctness (no-undef, no-unused-vars, no-redeclare, …) and a small style tier that codifies the codebase's existing 4-space/single-quote/LF conventions rather than imposing new ones. Component sources, the sample apps' ES modules, and the Node-side (tests/, build.js) each get their own globals/sourceType block.

Testing

The repo has two complementary suites plus a compile-time type check, and npm test runs all three (type check first, since it's fastest and fails loudest on .d.ts drift):

Setup (first time only)

npm install                          # install all dependencies (includes @playwright/test)
npx playwright install chromium firefox  # browser binaries (Firefox runs a few placement/keyboard specs)

Running tests

npm test                   # all three: type check, then unit, then E2E
npm run test:types         # tsc --noEmit over tests/types/ only (fastest — no browser needed)
npm run test:unit          # Node unit suite only (fast — no browser needed)
npm run test:e2e           # Playwright suite only
npm run test:headed        # E2E with a visible browser window
npm run test:ui            # interactive UI mode (pick & re-run tests)
npm run test:report        # open the last HTML report

To run a single unit-test file, pass it explicitly:

node --test tests/node/dom-scheduler.test.js

Note: node --test tests/node (a bare directory) fails with MODULE_NOT_FOUND on current Node versions. Use npm run test:unit (no argument — Node's default discovery finds tests/node/*.test.js), a glob, or an explicit file path.

Coverage

npm run test:coverage        # unit-suite coverage over src/, printed as a table
npm run test:coverage:e2e    # browser-side V8 coverage from the Playwright suite

test:coverage uses Node's built-in --experimental-test-coverage, scoped to src/** so build.js and the test files themselves stay out of the denominator. It also enforces line/branch/function thresholds (see the script in package.json) and exits non-zero when coverage drops below them. The thresholds sit just under the current numbers so they ratchet upward rather than blocking ordinary work — raise them when coverage improves.

It reports only files a test actually loads — a source file no unit test requires is absent from the table rather than shown as 0%. tests/node/dts-conformance.test.js loads every component source precisely so nothing can hide from the report.

test:coverage:e2e re-runs the Playwright suite with PW_COVERAGE=1, collecting Chromium's V8 coverage per test (via the fixture in tests/playwright/fixtures.js), then aggregates the results with scripts/aggregate-coverage.js. Two adjustments make that run reproducible:

The two percentages use different denominators (Node's line counting vs. the aggregator's statement-line heuristic) and are not addable. Read them per module, not as one repo-wide number — the suites are complementary by design, so a module that looks thin in one is often well covered by the other.

See docs/testing-and-coverage.md for the current per-module numbers, what the instrumented run excludes and why, the threshold ratchet policy, and guidance on writing tests that don't flake.

Continuous integration

.github/workflows/ci.yml runs on every push and pull request: npm run lint, npm run test:types (.d.ts type check), npm run test:coverage (unit suite + thresholds), and npm run build.

The Playwright suite is not run in CI — it needs a Chromium download and several minutes per run. Run it locally before merging:

npm run test:e2e

You can also run tests through the PowerShell helper, which scans tests/, launches Playwright with the discovered specs, then runs each tests/node/ file in its own node process (no type check):

./scripts/run-tests.ps1             # run discovered tests headless
./scripts/run-tests.ps1 -Headed     # run with visible browser
./scripts/run-tests.ps1 -Ui         # run in Playwright UI mode
./scripts/run-tests.ps1 -Grep "scroll"  # filter tests by title
./scripts/run-tests.ps1 -Project chromium  # one Playwright project only
./scripts/run-tests.ps1 -SkipNode   # Playwright only (-SkipPlaywright: Node only)

The Playwright config (playwright.config.js) automatically starts a static server for people-cities-js on port 4173 before tests run, so no manual server setup is needed.

Recording new tests

Use the helper scripts to launch Playwright Codegen — a record-and-playback tool that captures your browser interactions and generates test code:

PowerShell (scripts/record-test.ps1):

./scripts/record-test.ps1                        # interactive prompt
./scripts/record-test.ps1 -TestName "my-test"    # pass name directly

Or use the npm shortcut to open Codegen directly (save the output manually):

npm run test:record

The helper starts a static server, opens the browser with the Playwright inspector, and saves the recorded test into tests/playwright/. You should then review the generated file and add proper assertions.

Test structure

tests/
  types/                           # tsc --noEmit smoke tests (npm run test:types)
    vanilla-grid.dts-typecheck.ts         # exercises vanilla-grid.d.ts
    vanilla-grid-toolbar.dts-typecheck.ts # exercises vanilla-grid-toolbar.d.ts
    vanilla-resize-box.dts-typecheck.ts   # exercises vanilla-resize-box.d.ts
  playwright/                     # Playwright E2E specs (npm run test:e2e)
    _helpers.js                   # shared boot/row-injection helpers
    fixtures.js                   # test/expect re-export + PW_COVERAGE fixture
    fixtures/                     # standalone HTML pages for specs that need
                                  # a page of their own rather than a sample app
    scrolling-performance.spec.js # e.g. grid scroll performance
    ...                           # (add more spec files here)
  node/                           # Node built-in (node:test) unit suite
    _helpers.js                   # shared helpers (waitFor)             (npm run test:unit)
    dom-scheduler.test.js         # e.g. scheduler unit tests
    dts-conformance.test.js       # runtime name check of the .d.ts files
    ...                           # (add more *.test.js files here)
scripts/
  aggregate-coverage.js           # aggregates browser V8 coverage into a table
  run-e2e-coverage.js             # runs the E2E suite with coverage enabled
  record-test.ps1                 # PowerShell recording helper
  run-tests.ps1                   # PowerShell test-runner helper
playwright.config.js              # Playwright configuration
tsconfig.json                     # tsc config for tests/types/ (npm run test:types)

All three run by default. npm test runs the type check first (fast, fails early), then the unit suite, then the Playwright suite. Each can be run alone with npm run test:types / npm run test:unit / npm run test:e2e.

Writing a spec: import test / expect from ./fixtures.js, not from @playwright/test — the fixture module re-exports both and adds the opt-in coverage collector. A spec that imports @playwright/test directly still works, but contributes nothing to npm run test:coverage:e2e.

Waiting in a unit test: use waitFor(predicate) from tests/node/_helpers.js rather than a fixed setTimeout sleep. A fixed sleep ties the result to machine speed, and the suite runs its files concurrently — that is how the one historical flake in this suite happened.