Initial version

This commit is contained in:
2026-07-11 21:14:49 -05:00
commit e812fe8b72
8 changed files with 1226 additions and 0 deletions
+2
View File
@@ -0,0 +1,2 @@
node_modules/
*.tgz
+93
View File
@@ -0,0 +1,93 @@
# Checkbox Tree
A dependency-free, instance-based checkbox tree for browser ES modules. It
supports cascading selection, indeterminate parent states, expandable branches,
multiple independent instances, and optional local-storage persistence.
## Install
```sh
npm install @aaronaxvig/checkbox-tree
```
The package is not published yet. During local development, install it by path:
```sh
npm install ../checkbox-tree
```
## Usage
```js
import { CheckboxTree } from "@aaronaxvig/checkbox-tree";
import "@aaronaxvig/checkbox-tree/styles.css";
const tree = new CheckboxTree(document.querySelector("#example-tree"), {
initiallySelected: true,
storageKey: "example-tree-state",
onSelectionChange({ selectedIds, selectedNodes }) {
console.log(selectedIds, selectedNodes);
},
});
tree.setData([
{
id: "fruit",
label: "Fruit",
children: [
{ id: "apple", label: "Apple", metadata: { color: "red" } },
{ id: "pear", label: "Pear", metadata: { color: "green" } },
],
},
]);
```
For applications without a bundler, serve or copy the two files in `src/` and
use relative URLs:
```html
<link rel="stylesheet" href="./checkbox-tree.css">
<script type="module">
import { CheckboxTree } from "./checkbox-tree.js";
</script>
```
Every node requires unique string `id` and `label` properties. Nodes may also
have `children`, application-owned `metadata`, and `selectable` or `disabled`
flags.
## API
- `new CheckboxTree(container, options)` creates an independent instance.
- `setData(nodes)` validates and renders nodes, then restores saved state.
- `getSelectedIds()` returns selected node IDs.
- `getSelectedNodes()` returns selected source node objects.
- `setSelectedIds(ids, { notify })` replaces the selection.
- `restoreState()` restores selection and expansion state.
- `destroy()` removes listeners, markup, and the root CSS class.
Options:
- `initiallyCollapsed` defaults to `true`.
- `initiallySelected` defaults to `false`.
- `storageKey` defaults to `null`, which disables persistence.
- `onSelectionChange` receives `{ selectedIds, selectedNodes }`.
Styling is namespaced under `.checkbox-tree`. Override the custom properties
`--checkbox-tree-indent`, `--checkbox-tree-toggle-size`, and
`--checkbox-tree-hover-color` in the consuming application.
## Development
```sh
npm install
npm test
npm run pack:check
```
## Future demo ideas
- Add population metadata to the country, state, and county nodes and display the
total population represented by the current selection. Define the aggregation
rule carefully so checked ancestors and their checked descendants are not
counted twice; one option is to total only the most specific checked nodes.
+77
View File
@@ -0,0 +1,77 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Checkbox Tree Demo</title>
<link rel="stylesheet" href="../src/checkbox-tree.css">
<style>
body { max-width: 40rem; margin: 2rem auto; padding: 0 1rem; font-family: system-ui, sans-serif; }
#selection { padding: 0.75rem; background: #f3f3f3; }
</style>
</head>
<body>
<h1>Checkbox Tree</h1>
<p>A representative sample of countries, U.S. states, and North Dakota counties.</p>
<div id="tree"></div>
<h2>Selected places</h2>
<pre id="selection">[]</pre>
<script type="module">
import { CheckboxTree } from "../src/checkbox-tree.js";
const output = document.querySelector("#selection");
const tree = new CheckboxTree(document.querySelector("#tree"), {
initiallySelected: true,
onSelectionChange({ selectedIds }) {
output.textContent = JSON.stringify(selectedIds, null, 2);
},
});
tree.setData([
{ id: "country:argentina", label: "Argentina" },
{ id: "country:australia", label: "Australia" },
{ id: "country:brazil", label: "Brazil" },
{ id: "country:canada", label: "Canada" },
{ id: "country:france", label: "France" },
{ id: "country:germany", label: "Germany" },
{ id: "country:india", label: "India" },
{ id: "country:japan", label: "Japan" },
{ id: "country:mexico", label: "Mexico" },
{ id: "country:united-kingdom", label: "United Kingdom" },
{
id: "country:usa",
label: "United States of America (USA)",
children: [
{ id: "state:california", label: "California" },
{ id: "state:florida", label: "Florida" },
{ id: "state:illinois", label: "Illinois" },
{ id: "state:minnesota", label: "Minnesota" },
{ id: "state:montana", label: "Montana" },
{
id: "state:north-dakota",
label: "North Dakota",
children: [
{ id: "county:adams-nd", label: "Adams County" },
{ id: "county:barnes-nd", label: "Barnes County" },
{ id: "county:burleigh-nd", label: "Burleigh County" },
{ id: "county:cass-nd", label: "Cass County" },
{ id: "county:grand-forks-nd", label: "Grand Forks County" },
{ id: "county:mckenzie-nd", label: "McKenzie County" },
{ id: "county:morton-nd", label: "Morton County" },
{ id: "county:ramsey-nd", label: "Ramsey County" },
{ id: "county:stark-nd", label: "Stark County" },
{ id: "county:ward-nd", label: "Ward County" },
],
},
{ id: "state:new-york", label: "New York" },
{ id: "state:south-dakota", label: "South Dakota" },
{ id: "state:texas", label: "Texas" },
{ id: "state:washington", label: "Washington" },
],
},
{ id: "country:south-africa", label: "South Africa" },
]);
output.textContent = JSON.stringify(tree.getSelectedIds(), null, 2);
</script>
</body>
</html>
+548
View File
@@ -0,0 +1,548 @@
{
"name": "@aaronaxvig/checkbox-tree",
"version": "0.1.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@aaronaxvig/checkbox-tree",
"version": "0.1.0",
"devDependencies": {
"jsdom": "^26.1.0"
},
"engines": {
"node": ">=20"
}
},
"node_modules/@asamuzakjp/css-color": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-3.2.0.tgz",
"integrity": "sha512-K1A6z8tS3XsmCMM86xoWdn7Fkdn9m6RSVtocUrJYIwZnFVkng/PvkEoWtOWmP+Scc6saYWHWZYbndEEXxl24jw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@csstools/css-calc": "^2.1.3",
"@csstools/css-color-parser": "^3.0.9",
"@csstools/css-parser-algorithms": "^3.0.4",
"@csstools/css-tokenizer": "^3.0.3",
"lru-cache": "^10.4.3"
}
},
"node_modules/@csstools/color-helpers": {
"version": "5.1.0",
"resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-5.1.0.tgz",
"integrity": "sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA==",
"dev": true,
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/csstools"
},
{
"type": "opencollective",
"url": "https://opencollective.com/csstools"
}
],
"license": "MIT-0",
"engines": {
"node": ">=18"
}
},
"node_modules/@csstools/css-calc": {
"version": "2.1.4",
"resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-2.1.4.tgz",
"integrity": "sha512-3N8oaj+0juUw/1H3YwmDDJXCgTB1gKU6Hc/bB502u9zR0q2vd786XJH9QfrKIEgFlZmhZiq6epXl4rHqhzsIgQ==",
"dev": true,
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/csstools"
},
{
"type": "opencollective",
"url": "https://opencollective.com/csstools"
}
],
"license": "MIT",
"engines": {
"node": ">=18"
},
"peerDependencies": {
"@csstools/css-parser-algorithms": "^3.0.5",
"@csstools/css-tokenizer": "^3.0.4"
}
},
"node_modules/@csstools/css-color-parser": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-3.1.0.tgz",
"integrity": "sha512-nbtKwh3a6xNVIp/VRuXV64yTKnb1IjTAEEh3irzS+HkKjAOYLTGNb9pmVNntZ8iVBHcWDA2Dof0QtPgFI1BaTA==",
"dev": true,
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/csstools"
},
{
"type": "opencollective",
"url": "https://opencollective.com/csstools"
}
],
"license": "MIT",
"dependencies": {
"@csstools/color-helpers": "^5.1.0",
"@csstools/css-calc": "^2.1.4"
},
"engines": {
"node": ">=18"
},
"peerDependencies": {
"@csstools/css-parser-algorithms": "^3.0.5",
"@csstools/css-tokenizer": "^3.0.4"
}
},
"node_modules/@csstools/css-parser-algorithms": {
"version": "3.0.5",
"resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-3.0.5.tgz",
"integrity": "sha512-DaDeUkXZKjdGhgYaHNJTV9pV7Y9B3b644jCLs9Upc3VeNGg6LWARAT6O+Q+/COo+2gg/bM5rhpMAtf70WqfBdQ==",
"dev": true,
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/csstools"
},
{
"type": "opencollective",
"url": "https://opencollective.com/csstools"
}
],
"license": "MIT",
"engines": {
"node": ">=18"
},
"peerDependencies": {
"@csstools/css-tokenizer": "^3.0.4"
}
},
"node_modules/@csstools/css-tokenizer": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-3.0.4.tgz",
"integrity": "sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==",
"dev": true,
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/csstools"
},
{
"type": "opencollective",
"url": "https://opencollective.com/csstools"
}
],
"license": "MIT",
"engines": {
"node": ">=18"
}
},
"node_modules/agent-base": {
"version": "7.1.4",
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz",
"integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 14"
}
},
"node_modules/cssstyle": {
"version": "4.6.0",
"resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-4.6.0.tgz",
"integrity": "sha512-2z+rWdzbbSZv6/rhtvzvqeZQHrBaqgogqt85sqFNbabZOuFbCVFb8kPeEtZjiKkbrm395irpNKiYeFeLiQnFPg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@asamuzakjp/css-color": "^3.2.0",
"rrweb-cssom": "^0.8.0"
},
"engines": {
"node": ">=18"
}
},
"node_modules/data-urls": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/data-urls/-/data-urls-5.0.0.tgz",
"integrity": "sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg==",
"dev": true,
"license": "MIT",
"dependencies": {
"whatwg-mimetype": "^4.0.0",
"whatwg-url": "^14.0.0"
},
"engines": {
"node": ">=18"
}
},
"node_modules/debug": {
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
"dev": true,
"license": "MIT",
"dependencies": {
"ms": "^2.1.3"
},
"engines": {
"node": ">=6.0"
},
"peerDependenciesMeta": {
"supports-color": {
"optional": true
}
}
},
"node_modules/decimal.js": {
"version": "10.6.0",
"resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz",
"integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==",
"dev": true,
"license": "MIT"
},
"node_modules/entities": {
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz",
"integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==",
"dev": true,
"license": "BSD-2-Clause",
"engines": {
"node": ">=0.12"
},
"funding": {
"url": "https://github.com/fb55/entities?sponsor=1"
}
},
"node_modules/html-encoding-sniffer": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-4.0.0.tgz",
"integrity": "sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"whatwg-encoding": "^3.1.1"
},
"engines": {
"node": ">=18"
}
},
"node_modules/http-proxy-agent": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz",
"integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==",
"dev": true,
"license": "MIT",
"dependencies": {
"agent-base": "^7.1.0",
"debug": "^4.3.4"
},
"engines": {
"node": ">= 14"
}
},
"node_modules/https-proxy-agent": {
"version": "7.0.6",
"resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz",
"integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==",
"dev": true,
"license": "MIT",
"dependencies": {
"agent-base": "^7.1.2",
"debug": "4"
},
"engines": {
"node": ">= 14"
}
},
"node_modules/iconv-lite": {
"version": "0.6.3",
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz",
"integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==",
"dev": true,
"license": "MIT",
"dependencies": {
"safer-buffer": ">= 2.1.2 < 3.0.0"
},
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/is-potential-custom-element-name": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz",
"integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==",
"dev": true,
"license": "MIT"
},
"node_modules/jsdom": {
"version": "26.1.0",
"resolved": "https://registry.npmjs.org/jsdom/-/jsdom-26.1.0.tgz",
"integrity": "sha512-Cvc9WUhxSMEo4McES3P7oK3QaXldCfNWp7pl2NNeiIFlCoLr3kfq9kb1fxftiwk1FLV7CvpvDfonxtzUDeSOPg==",
"dev": true,
"license": "MIT",
"dependencies": {
"cssstyle": "^4.2.1",
"data-urls": "^5.0.0",
"decimal.js": "^10.5.0",
"html-encoding-sniffer": "^4.0.0",
"http-proxy-agent": "^7.0.2",
"https-proxy-agent": "^7.0.6",
"is-potential-custom-element-name": "^1.0.1",
"nwsapi": "^2.2.16",
"parse5": "^7.2.1",
"rrweb-cssom": "^0.8.0",
"saxes": "^6.0.0",
"symbol-tree": "^3.2.4",
"tough-cookie": "^5.1.1",
"w3c-xmlserializer": "^5.0.0",
"webidl-conversions": "^7.0.0",
"whatwg-encoding": "^3.1.1",
"whatwg-mimetype": "^4.0.0",
"whatwg-url": "^14.1.1",
"ws": "^8.18.0",
"xml-name-validator": "^5.0.0"
},
"engines": {
"node": ">=18"
},
"peerDependencies": {
"canvas": "^3.0.0"
},
"peerDependenciesMeta": {
"canvas": {
"optional": true
}
}
},
"node_modules/lru-cache": {
"version": "10.4.3",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz",
"integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==",
"dev": true,
"license": "ISC"
},
"node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"dev": true,
"license": "MIT"
},
"node_modules/nwsapi": {
"version": "2.2.24",
"resolved": "https://registry.npmjs.org/nwsapi/-/nwsapi-2.2.24.tgz",
"integrity": "sha512-7YRhZ3jS45LwmSCT4b2sVFHt/WuovaktDU07QrtOBY2PXskss5a9jfmR9jptyumwXST+rFjrmppMY1KT/yn35A==",
"dev": true,
"license": "MIT"
},
"node_modules/parse5": {
"version": "7.3.0",
"resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz",
"integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==",
"dev": true,
"license": "MIT",
"dependencies": {
"entities": "^6.0.0"
},
"funding": {
"url": "https://github.com/inikulin/parse5?sponsor=1"
}
},
"node_modules/punycode": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
"integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/rrweb-cssom": {
"version": "0.8.0",
"resolved": "https://registry.npmjs.org/rrweb-cssom/-/rrweb-cssom-0.8.0.tgz",
"integrity": "sha512-guoltQEx+9aMf2gDZ0s62EcV8lsXR+0w8915TC3ITdn2YueuNjdAYh/levpU9nFaoChh9RUS5ZdQMrKfVEN9tw==",
"dev": true,
"license": "MIT"
},
"node_modules/safer-buffer": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
"dev": true,
"license": "MIT"
},
"node_modules/saxes": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz",
"integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==",
"dev": true,
"license": "ISC",
"dependencies": {
"xmlchars": "^2.2.0"
},
"engines": {
"node": ">=v12.22.7"
}
},
"node_modules/symbol-tree": {
"version": "3.2.4",
"resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz",
"integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==",
"dev": true,
"license": "MIT"
},
"node_modules/tldts": {
"version": "6.1.86",
"resolved": "https://registry.npmjs.org/tldts/-/tldts-6.1.86.tgz",
"integrity": "sha512-WMi/OQ2axVTf/ykqCQgXiIct+mSQDFdH2fkwhPwgEwvJ1kSzZRiinb0zF2Xb8u4+OqPChmyI6MEu4EezNJz+FQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"tldts-core": "^6.1.86"
},
"bin": {
"tldts": "bin/cli.js"
}
},
"node_modules/tldts-core": {
"version": "6.1.86",
"resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-6.1.86.tgz",
"integrity": "sha512-Je6p7pkk+KMzMv2XXKmAE3McmolOQFdxkKw0R8EYNr7sELW46JqnNeTX8ybPiQgvg1ymCoF8LXs5fzFaZvJPTA==",
"dev": true,
"license": "MIT"
},
"node_modules/tough-cookie": {
"version": "5.1.2",
"resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-5.1.2.tgz",
"integrity": "sha512-FVDYdxtnj0G6Qm/DhNPSb8Ju59ULcup3tuJxkFb5K8Bv2pUXILbf0xZWU8PX8Ov19OXljbUyveOFwRMwkXzO+A==",
"dev": true,
"license": "BSD-3-Clause",
"dependencies": {
"tldts": "^6.1.32"
},
"engines": {
"node": ">=16"
}
},
"node_modules/tr46": {
"version": "5.1.1",
"resolved": "https://registry.npmjs.org/tr46/-/tr46-5.1.1.tgz",
"integrity": "sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==",
"dev": true,
"license": "MIT",
"dependencies": {
"punycode": "^2.3.1"
},
"engines": {
"node": ">=18"
}
},
"node_modules/w3c-xmlserializer": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz",
"integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==",
"dev": true,
"license": "MIT",
"dependencies": {
"xml-name-validator": "^5.0.0"
},
"engines": {
"node": ">=18"
}
},
"node_modules/webidl-conversions": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz",
"integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==",
"dev": true,
"license": "BSD-2-Clause",
"engines": {
"node": ">=12"
}
},
"node_modules/whatwg-encoding": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-3.1.1.tgz",
"integrity": "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==",
"deprecated": "Use @exodus/bytes instead for a more spec-conformant and faster implementation",
"dev": true,
"license": "MIT",
"dependencies": {
"iconv-lite": "0.6.3"
},
"engines": {
"node": ">=18"
}
},
"node_modules/whatwg-mimetype": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-4.0.0.tgz",
"integrity": "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=18"
}
},
"node_modules/whatwg-url": {
"version": "14.2.0",
"resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-14.2.0.tgz",
"integrity": "sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==",
"dev": true,
"license": "MIT",
"dependencies": {
"tr46": "^5.1.0",
"webidl-conversions": "^7.0.0"
},
"engines": {
"node": ">=18"
}
},
"node_modules/ws": {
"version": "8.21.0",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz",
"integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=10.0.0"
},
"peerDependencies": {
"bufferutil": "^4.0.1",
"utf-8-validate": ">=5.0.2"
},
"peerDependenciesMeta": {
"bufferutil": {
"optional": true
},
"utf-8-validate": {
"optional": true
}
}
},
"node_modules/xml-name-validator": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz",
"integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==",
"dev": true,
"license": "Apache-2.0",
"engines": {
"node": ">=18"
}
},
"node_modules/xmlchars": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz",
"integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==",
"dev": true,
"license": "MIT"
}
}
}
+30
View File
@@ -0,0 +1,30 @@
{
"name": "@aaronaxvig/checkbox-tree",
"version": "0.1.0",
"description": "A dependency-free checkbox tree for browser ES modules",
"type": "module",
"exports": {
".": "./src/checkbox-tree.js",
"./styles.css": "./src/checkbox-tree.css"
},
"files": [
"src",
"README.md"
],
"scripts": {
"test": "node --test",
"pack:check": "npm pack --dry-run"
},
"keywords": [
"checkbox",
"tree",
"browser",
"es-modules"
],
"devDependencies": {
"jsdom": "^26.1.0"
},
"engines": {
"node": ">=20"
}
}
+82
View File
@@ -0,0 +1,82 @@
.checkbox-tree {
--checkbox-tree-indent: 18px;
--checkbox-tree-toggle-size: 18px;
--checkbox-tree-hover-color: #2c3e50;
font-size: 14px;
line-height: 1.4;
}
.checkbox-tree__item {
margin: 1px 0;
}
.checkbox-tree__row {
display: flex;
align-items: center;
min-height: 24px;
}
.checkbox-tree__toggle,
.checkbox-tree__toggle-spacer {
display: inline-flex;
align-items: center;
justify-content: center;
flex: 0 0 var(--checkbox-tree-toggle-size);
width: var(--checkbox-tree-toggle-size);
height: var(--checkbox-tree-toggle-size);
margin-right: 2px;
}
.checkbox-tree__toggle {
padding: 0;
border: 0;
background: transparent;
color: inherit;
cursor: pointer;
font: inherit;
}
.checkbox-tree__toggle[aria-expanded="true"] {
transform: rotate(90deg);
}
.checkbox-tree__toggle:focus-visible {
outline: 2px solid currentColor;
outline-offset: 1px;
}
.checkbox-tree__label {
display: inline-flex;
align-items: center;
padding: 2px 0;
cursor: pointer;
user-select: none;
}
.checkbox-tree__item:has(> .checkbox-tree__children) > .checkbox-tree__row > .checkbox-tree__label {
font-weight: 500;
}
.checkbox-tree__label:hover {
color: var(--checkbox-tree-hover-color);
}
.checkbox-tree__checkbox {
flex-shrink: 0;
width: 16px;
height: 16px;
margin: 0 4px 0 0;
cursor: pointer;
}
.checkbox-tree__checkbox:indeterminate {
accent-color: #f39c12;
}
.checkbox-tree__children {
margin-left: var(--checkbox-tree-indent);
}
.checkbox-tree__children[hidden] {
display: none;
}
+289
View File
@@ -0,0 +1,289 @@
const DEFAULT_OPTIONS = {
initiallyCollapsed: true,
initiallySelected: false,
storageKey: null,
onSelectionChange: null
};
export class CheckboxTree {
constructor(container, options = {}) {
if (!(container instanceof Element)) {
throw new TypeError('CheckboxTree requires a container element.');
}
this.container = container;
this.options = { ...DEFAULT_OPTIONS, ...options };
this.nodesById = new Map();
this.rootNodes = [];
this.handleChange = this.handleChange.bind(this);
this.handleClick = this.handleClick.bind(this);
this.container.classList.add('checkbox-tree');
this.container.addEventListener('change', this.handleChange);
this.container.addEventListener('click', this.handleClick);
}
setData(nodes) {
if (!Array.isArray(nodes)) {
throw new TypeError('CheckboxTree data must be an array of nodes.');
}
this.nodesById.clear();
this.rootNodes = nodes;
this.indexNodes(nodes);
this.render();
this.updateAllParentCheckboxes();
this.restoreState();
}
getSelectedIds() {
return this.getSelectedNodes().map(node => node.id);
}
getSelectedNodes() {
return Array.from(this.container.querySelectorAll(
'.checkbox-tree__checkbox:checked[data-selectable="true"]'
)).map(checkbox => this.nodesById.get(checkbox.dataset.nodeId)).filter(Boolean);
}
setSelectedIds(ids, { notify = false } = {}) {
const selectedIds = new Set(ids);
this.container.querySelectorAll('.checkbox-tree__checkbox').forEach(checkbox => {
checkbox.checked = checkbox.dataset.selectable === 'true' && selectedIds.has(checkbox.dataset.nodeId);
checkbox.indeterminate = false;
});
this.updateAllParentCheckboxes();
this.saveState();
if (notify) {
this.notifySelectionChange();
}
}
destroy() {
this.container.removeEventListener('change', this.handleChange);
this.container.removeEventListener('click', this.handleClick);
this.container.classList.remove('checkbox-tree');
this.container.replaceChildren();
this.nodesById.clear();
}
indexNodes(nodes) {
nodes.forEach(node => {
if (!node || typeof node.id !== 'string' || !node.id || typeof node.label !== 'string') {
throw new TypeError('Each CheckboxTree node requires non-empty string id and label properties.');
}
if (this.nodesById.has(node.id)) {
throw new Error(`Duplicate CheckboxTree node id: ${node.id}`);
}
this.nodesById.set(node.id, node);
if (node.children !== undefined) {
if (!Array.isArray(node.children)) {
throw new TypeError(`CheckboxTree node children must be an array: ${node.id}`);
}
this.indexNodes(node.children);
}
});
}
render() {
const fragment = document.createDocumentFragment();
this.rootNodes.forEach(node => fragment.appendChild(this.createNodeElement(node)));
this.container.replaceChildren(fragment);
}
createNodeElement(node) {
const item = document.createElement('div');
item.className = 'checkbox-tree__item';
item.dataset.nodeId = node.id;
const row = document.createElement('div');
row.className = 'checkbox-tree__row';
const hasChildren = Array.isArray(node.children) && node.children.length > 0;
if (hasChildren) {
const toggle = document.createElement('button');
toggle.type = 'button';
toggle.className = 'checkbox-tree__toggle';
toggle.dataset.action = 'toggle';
toggle.setAttribute('aria-label', `Expand ${node.label}`);
toggle.setAttribute('aria-expanded', 'false');
toggle.textContent = '▶';
row.appendChild(toggle);
} else {
const spacer = document.createElement('span');
spacer.className = 'checkbox-tree__toggle-spacer';
spacer.setAttribute('aria-hidden', 'true');
row.appendChild(spacer);
}
const label = document.createElement('label');
label.className = 'checkbox-tree__label';
if (node.selectable !== false) {
const checkbox = document.createElement('input');
checkbox.type = 'checkbox';
checkbox.className = 'checkbox-tree__checkbox';
checkbox.dataset.nodeId = node.id;
checkbox.dataset.selectable = 'true';
checkbox.disabled = node.disabled === true;
checkbox.checked = this.options.initiallySelected && !checkbox.disabled;
label.appendChild(checkbox);
}
label.appendChild(document.createTextNode(node.label));
row.appendChild(label);
item.appendChild(row);
if (hasChildren) {
const children = document.createElement('div');
children.className = 'checkbox-tree__children';
children.hidden = this.options.initiallyCollapsed;
node.children.forEach(child => children.appendChild(this.createNodeElement(child)));
item.appendChild(children);
if (!this.options.initiallyCollapsed) {
this.setExpanded(item, true);
}
}
return item;
}
handleClick(event) {
const toggle = event.target.closest('.checkbox-tree__toggle[data-action="toggle"]');
if (!toggle || !this.container.contains(toggle)) {
return;
}
const item = toggle.closest('.checkbox-tree__item');
this.setExpanded(item, toggle.getAttribute('aria-expanded') !== 'true');
this.saveState();
}
handleChange(event) {
const checkbox = event.target.closest('.checkbox-tree__checkbox');
if (!checkbox || !this.container.contains(checkbox)) {
return;
}
const item = checkbox.closest('.checkbox-tree__item');
const children = this.directChildrenContainer(item);
if (children) {
children.querySelectorAll('.checkbox-tree__checkbox:not(:disabled)').forEach(child => {
child.checked = checkbox.checked;
child.indeterminate = false;
});
}
checkbox.indeterminate = false;
this.updateAncestorCheckboxes(item);
this.saveState();
this.notifySelectionChange();
}
notifySelectionChange() {
if (typeof this.options.onSelectionChange === 'function') {
this.options.onSelectionChange({
selectedIds: this.getSelectedIds(),
selectedNodes: this.getSelectedNodes()
});
}
}
setExpanded(item, expanded) {
const toggle = item.querySelector(':scope > .checkbox-tree__row > .checkbox-tree__toggle');
const children = this.directChildrenContainer(item);
if (!toggle || !children) {
return;
}
children.hidden = !expanded;
toggle.setAttribute('aria-expanded', String(expanded));
const label = this.nodesById.get(item.dataset.nodeId)?.label ?? 'branch';
toggle.setAttribute('aria-label', `${expanded ? 'Collapse' : 'Expand'} ${label}`);
}
directChildrenContainer(item) {
return item.querySelector(':scope > .checkbox-tree__children');
}
directCheckbox(item) {
return item.querySelector(':scope > .checkbox-tree__row .checkbox-tree__checkbox');
}
updateAncestorCheckboxes(item) {
let parentItem = item.parentElement?.closest('.checkbox-tree__item');
while (parentItem && this.container.contains(parentItem)) {
this.updateParentCheckbox(parentItem);
parentItem = parentItem.parentElement?.closest('.checkbox-tree__item');
}
}
updateAllParentCheckboxes() {
const parents = Array.from(this.container.querySelectorAll('.checkbox-tree__item'))
.filter(item => this.directChildrenContainer(item));
parents.reverse().forEach(item => this.updateParentCheckbox(item));
}
updateParentCheckbox(item) {
const checkbox = this.directCheckbox(item);
const children = this.directChildrenContainer(item);
if (!checkbox || !children) {
return;
}
const childCheckboxes = Array.from(children.querySelectorAll(
':scope > .checkbox-tree__item > .checkbox-tree__row .checkbox-tree__checkbox'
)).filter(child => !child.disabled);
const hasSelection = childCheckboxes.some(child => child.checked || child.indeterminate);
const allSelected = childCheckboxes.length > 0 && childCheckboxes.every(child => child.checked);
checkbox.checked = allSelected;
checkbox.indeterminate = hasSelection && !allSelected;
}
saveState() {
if (!this.options.storageKey) {
return;
}
const collapsedIds = Array.from(this.container.querySelectorAll('.checkbox-tree__children[hidden]'))
.map(children => children.parentElement.dataset.nodeId);
try {
localStorage.setItem(this.options.storageKey, JSON.stringify({
version: 1,
selectedIds: this.getSelectedIds(),
collapsedIds
}));
} catch {
// Persistence is optional; storage may be disabled or full.
}
}
restoreState() {
if (!this.options.storageKey) {
return;
}
let state;
try {
state = JSON.parse(localStorage.getItem(this.options.storageKey));
} catch {
return;
}
if (!state || typeof state !== 'object') {
return;
}
if (Array.isArray(state.selectedIds)) {
this.setSelectedIds(state.selectedIds);
}
if (Array.isArray(state.collapsedIds)) {
const collapsedIds = new Set(state.collapsedIds);
this.container.querySelectorAll('.checkbox-tree__item').forEach(item => {
if (this.directChildrenContainer(item)) {
this.setExpanded(item, !collapsedIds.has(item.dataset.nodeId));
}
});
}
}
}
+105
View File
@@ -0,0 +1,105 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { JSDOM } from 'jsdom';
const dom = new JSDOM('<!doctype html><html><body></body></html>', { url: 'https://example.test/' });
globalThis.window = dom.window;
globalThis.document = dom.window.document;
globalThis.Element = dom.window.Element;
globalThis.localStorage = dom.window.localStorage;
const { CheckboxTree } = await import('../src/checkbox-tree.js');
function createTree(options = {}) {
const container = document.createElement('div');
document.body.replaceChildren(container);
const tree = new CheckboxTree(container, options);
tree.setData([
{
id: 'parent',
label: 'Parent',
metadata: { kind: 'parent' },
children: [
{ id: 'first', label: 'First', metadata: { kind: 'leaf' } },
{ id: 'second', label: 'Second', metadata: { kind: 'leaf' } }
]
}
]);
return { container, tree };
}
function checkbox(container, id) {
return container.querySelector(`.checkbox-tree__checkbox[data-node-id="${id}"]`);
}
test('selecting a parent selects all descendants and emits selected nodes', () => {
let change;
const { container, tree } = createTree({ onSelectionChange: event => { change = event; } });
const parent = checkbox(container, 'parent');
parent.checked = true;
parent.dispatchEvent(new window.Event('change', { bubbles: true }));
assert.deepEqual(tree.getSelectedIds(), ['parent', 'first', 'second']);
assert.deepEqual(change.selectedIds, ['parent', 'first', 'second']);
assert.equal(change.selectedNodes[1].metadata.kind, 'leaf');
});
test('partial child selection makes the parent indeterminate', () => {
const { container, tree } = createTree();
const first = checkbox(container, 'first');
first.checked = true;
first.dispatchEvent(new window.Event('change', { bubbles: true }));
const parent = checkbox(container, 'parent');
assert.equal(parent.checked, false);
assert.equal(parent.indeterminate, true);
assert.deepEqual(tree.getSelectedIds(), ['first']);
});
test('can initially select every selectable node', () => {
const { tree } = createTree({ initiallySelected: true });
assert.deepEqual(tree.getSelectedIds(), ['parent', 'first', 'second']);
});
test('persists selection and collapsed branches per configured key', () => {
localStorage.clear();
const first = createTree({ storageKey: 'example-tree' });
checkbox(first.container, 'first').click();
const toggle = first.container.querySelector('.checkbox-tree__toggle');
toggle.click();
assert.equal(toggle.getAttribute('aria-expanded'), 'true');
const second = createTree({ storageKey: 'example-tree' });
assert.deepEqual(second.tree.getSelectedIds(), ['first']);
assert.equal(second.container.querySelector('.checkbox-tree__toggle').getAttribute('aria-expanded'), 'true');
});
test('supports independent instances and removes listeners on destroy', () => {
const firstContainer = document.createElement('div');
const secondContainer = document.createElement('div');
document.body.replaceChildren(firstContainer, secondContainer);
const first = new CheckboxTree(firstContainer);
const second = new CheckboxTree(secondContainer);
const data = [{ id: 'node', label: 'Node' }];
first.setData(data);
second.setData(data);
checkbox(firstContainer, 'node').click();
assert.deepEqual(first.getSelectedIds(), ['node']);
assert.deepEqual(second.getSelectedIds(), []);
first.destroy();
assert.equal(firstContainer.childElementCount, 0);
assert.equal(firstContainer.classList.contains('checkbox-tree'), false);
});
test('rejects duplicate node ids', () => {
const container = document.createElement('div');
const tree = new CheckboxTree(container);
assert.throws(
() => tree.setData([{ id: 'same', label: 'One' }, { id: 'same', label: 'Two' }]),
/Duplicate CheckboxTree node id/
);
});