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):
- Type check (
tests/types/) —tsc --noEmitcompiling hand-written*.dts-typecheck.tssmoke tests against each component's hand-authored.d.tsfile. No browser, no server, nothing executed — a compile error is the assertion. Named.dts-typecheck.tsrather than*.test.ts/*-test.tsdeliberately: those suffixes match Node's own--testfile-discovery glob, which would otherwise try to execute these files (and fail — they use browser globals likedocument/HTMLElementwith no DOM present). Catches type-level drift (a param/return type going stale) that the unit suite'stests/node/dts-conformance.test.jscan't, since that one only checks method/property names exist at runtime, not their types. - Unit suite (
tests/node/) — Node's built-innode:testrunner, no browser and no server. Covers the pure-logic modules: data managers, filter model, scheduler, comparators, storage providers, templates. - E2E suite (
tests/playwright/) — Playwright driving real browser interactions against the sample apps. Covers everything that touches the DOM: rendering, virtualization, header menus, resizing, theming.
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 withMODULE_NOT_FOUNDon current Node versions. Usenpm run test:unit(no argument — Node's default discovery findstests/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:
- Two groups of specs are excluded from instrumented runs (
INSTRUMENTATION_SENSITIVE_SPECSinplaywright.config.js). Performance specs, because collecting coverage slows the page enough to break their frame-budget and slice-latency assertions. Live network specs (the four*-smokespecs, pluslate-children-framework-integrationandsample-search-box), because they let a real service respond — and GitHub's unauthenticated Search API allows only 10 requests/minute, which repeated coverage runs exhaust. Both groups are skipped, not weakened, and run in full undernpm run test:e2e. - It runs single-worker (
--workers=1). A few specs assert on a race — such as a host-set toolbar status message versus an error status — and lose it when instrumentation overhead is compounded by parallel workers competing for CPU. Serialising the run costs wall-clock time only, and the run is opt-in and off the CI path.
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 testruns the type check first (fast, fails early), then the unit suite, then the Playwright suite. Each can be run alone withnpm run test:types/npm run test:unit/npm run test:e2e.
Writing a spec: import
test/expectfrom./fixtures.js, not from@playwright/test— the fixture module re-exports both and adds the opt-in coverage collector. A spec that imports@playwright/testdirectly still works, but contributes nothing tonpm run test:coverage:e2e.
Waiting in a unit test: use
waitFor(predicate)fromtests/node/_helpers.jsrather than a fixedsetTimeoutsleep. 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.