commit e812fe8b7252a290d0d506fa92fdf73c9e95285d Author: aaronaxvig Date: Sat Jul 11 21:14:49 2026 -0500 Initial version diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..2d98f97 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +node_modules/ +*.tgz diff --git a/README.md b/README.md new file mode 100644 index 0000000..c12238b --- /dev/null +++ b/README.md @@ -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 + + +``` + +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. diff --git a/demo/index.html b/demo/index.html new file mode 100644 index 0000000..877ae5a --- /dev/null +++ b/demo/index.html @@ -0,0 +1,77 @@ + + + + + + Checkbox Tree Demo + + + + +

Checkbox Tree

+

A representative sample of countries, U.S. states, and North Dakota counties.

+
+

Selected places

+
[]
+ + + diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..2fd0c85 --- /dev/null +++ b/package-lock.json @@ -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" + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..ae643de --- /dev/null +++ b/package.json @@ -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" + } +} diff --git a/src/checkbox-tree.css b/src/checkbox-tree.css new file mode 100644 index 0000000..4e5bf8f --- /dev/null +++ b/src/checkbox-tree.css @@ -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; +} diff --git a/src/checkbox-tree.js b/src/checkbox-tree.js new file mode 100644 index 0000000..c470d48 --- /dev/null +++ b/src/checkbox-tree.js @@ -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)); + } + }); + } + } +} diff --git a/test/checkbox-tree.test.js b/test/checkbox-tree.test.js new file mode 100644 index 0000000..bade72e --- /dev/null +++ b/test/checkbox-tree.test.js @@ -0,0 +1,105 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { JSDOM } from 'jsdom'; + +const dom = new JSDOM('', { 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/ + ); +});