Initial version
This commit is contained in:
@@ -0,0 +1,2 @@
|
||||
node_modules/
|
||||
*.tgz
|
||||
@@ -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.
|
||||
@@ -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>
|
||||
Generated
+548
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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/
|
||||
);
|
||||
});
|
||||
Reference in New Issue
Block a user