Refactor of tree stuff
This commit is contained in:
Vendored
+3
@@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"liveServer.settings.root": "/web"
|
||||||
|
}
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
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('../web/lib/checkbox-tree/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('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/
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -8,6 +8,7 @@
|
|||||||
<link rel="icon" type="image/svg+xml" href="icons/favicon.svg">
|
<link rel="icon" type="image/svg+xml" href="icons/favicon.svg">
|
||||||
<link rel="apple-touch-icon" href="apple-touch-icon.png">
|
<link rel="apple-touch-icon" href="apple-touch-icon.png">
|
||||||
<link rel="apple-touch-icon-precomposed" href="apple-touch-icon-precomposed.png">
|
<link rel="apple-touch-icon-precomposed" href="apple-touch-icon-precomposed.png">
|
||||||
|
<link rel="stylesheet" href="lib/checkbox-tree/checkbox-tree.css">
|
||||||
<link rel="stylesheet" href="styles.css">
|
<link rel="stylesheet" href="styles.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|||||||
+46
-296
@@ -1,85 +1,39 @@
|
|||||||
|
import { CheckboxTree } from '../lib/checkbox-tree/checkbox-tree.js';
|
||||||
import { getSortedKeys } from './sort.js';
|
import { getSortedKeys } from './sort.js';
|
||||||
|
|
||||||
const STORAGE_KEY = 'tree-state';
|
const STORAGE_KEY = 'tree-state-v2';
|
||||||
|
let productTree = null;
|
||||||
let treeContainer = null;
|
|
||||||
let onSelectionChangeHandler = null;
|
|
||||||
|
|
||||||
export function initializeTree(containerId, onSelectionChange) {
|
export function initializeTree(containerId, onSelectionChange) {
|
||||||
treeContainer = document.getElementById(containerId);
|
const container = document.getElementById(containerId);
|
||||||
onSelectionChangeHandler = onSelectionChange;
|
if (!container) {
|
||||||
}
|
throw new Error(`Tree container not found: ${containerId}`);
|
||||||
|
|
||||||
export function renderProductTree(productsData) {
|
|
||||||
if (!treeContainer) {
|
|
||||||
throw new Error('Tree is not initialized. Call initializeTree first.');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
treeContainer.innerHTML = '';
|
productTree?.destroy();
|
||||||
|
productTree = new CheckboxTree(container, {
|
||||||
getSortedKeys(productsData).forEach(product => {
|
storageKey: STORAGE_KEY,
|
||||||
const productNode = createTreeNode(product, productsData[product], [product]);
|
initiallyCollapsed: true,
|
||||||
treeContainer.appendChild(productNode);
|
onSelectionChange
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function renderProductTree(productsData) {
|
||||||
|
requireTree().setData(toTreeNodes(productsData));
|
||||||
|
}
|
||||||
|
|
||||||
export function getCheckedPaths() {
|
export function getCheckedPaths() {
|
||||||
if (!treeContainer) {
|
return productTree ? productTree.getSelectedNodes().map(node => node.metadata.path) : [];
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
const checkedBoxes = treeContainer.querySelectorAll('input[type="checkbox"]:checked');
|
|
||||||
|
|
||||||
return Array.from(checkedBoxes)
|
|
||||||
.map(cb => cb.dataset.path)
|
|
||||||
.filter(Boolean)
|
|
||||||
.map(pathText => {
|
|
||||||
try {
|
|
||||||
return JSON.parse(pathText);
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Invalid checkbox path data:', pathText, error);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.filter(pathArray => Array.isArray(pathArray) && pathArray.length > 0);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getCheckedFileRefs() {
|
export function getCheckedFileRefs() {
|
||||||
if (!treeContainer) {
|
|
||||||
return {
|
|
||||||
addressedFiles: [],
|
|
||||||
knownFiles: []
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const addressedFiles = [];
|
const addressedFiles = [];
|
||||||
const knownFiles = [];
|
const knownFiles = [];
|
||||||
const checkedBoxes = treeContainer.querySelectorAll('input[type="checkbox"]:checked[data-has-files="1"]');
|
|
||||||
|
|
||||||
checkedBoxes.forEach(cb => {
|
|
||||||
const pathText = cb.dataset.path;
|
|
||||||
if (!pathText) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
let path;
|
|
||||||
try {
|
|
||||||
path = JSON.parse(pathText);
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Invalid checkbox path data:', pathText, error);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!Array.isArray(path) || path.length === 0) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
productTree?.getSelectedNodes().forEach(node => {
|
||||||
|
const { path, addressed = [], known = [] } = node.metadata;
|
||||||
const productKey = path[0];
|
const productKey = path[0];
|
||||||
|
|
||||||
const addressed = parseJsonArray(cb.dataset.addressed);
|
|
||||||
addressed.forEach(fileName => addressedFiles.push({ productKey, fileName }));
|
addressed.forEach(fileName => addressedFiles.push({ productKey, fileName }));
|
||||||
|
|
||||||
const known = parseJsonArray(cb.dataset.known);
|
|
||||||
known.forEach(fileName => knownFiles.push({ productKey, fileName }));
|
known.forEach(fileName => knownFiles.push({ productKey, fileName }));
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -89,255 +43,51 @@ export function getCheckedFileRefs() {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function createTreeNode(name, value, path) {
|
// State restoration now happens when setData renders the generic tree.
|
||||||
const container = document.createElement('div');
|
export function restoreTreeState() {}
|
||||||
container.className = 'tree-item';
|
|
||||||
|
|
||||||
const isObjectValue = value && typeof value === 'object';
|
function requireTree() {
|
||||||
const hasFiles = isObjectValue && ('addressed' in value || 'known' in value);
|
if (!productTree) {
|
||||||
const childKeys = isObjectValue
|
throw new Error('Tree is not initialized. Call initializeTree first.');
|
||||||
? getSortedKeys(value).filter(key => key !== 'addressed' && key !== 'known')
|
|
||||||
: [];
|
|
||||||
const hasChildren = childKeys.length > 0;
|
|
||||||
|
|
||||||
if (hasChildren) {
|
|
||||||
container.classList.add('parent');
|
|
||||||
|
|
||||||
const toggle = document.createElement('span');
|
|
||||||
toggle.className = 'tree-toggle';
|
|
||||||
toggle.textContent = '▼';
|
|
||||||
toggle.style.cursor = 'pointer';
|
|
||||||
|
|
||||||
const label = document.createElement('label');
|
|
||||||
const checkbox = createCheckbox(path, hasFiles ? value : undefined);
|
|
||||||
label.appendChild(checkbox);
|
|
||||||
label.appendChild(document.createTextNode(name));
|
|
||||||
|
|
||||||
const childrenContainer = document.createElement('div');
|
|
||||||
childrenContainer.className = 'tree-children';
|
|
||||||
|
|
||||||
childKeys.forEach(childKey => {
|
|
||||||
const childNode = createTreeNode(childKey, value[childKey], [...path, childKey]);
|
|
||||||
childrenContainer.appendChild(childNode);
|
|
||||||
});
|
|
||||||
|
|
||||||
if (shouldStartCollapsed(value)) {
|
|
||||||
childrenContainer.classList.add('collapsed');
|
|
||||||
toggle.textContent = '▶';
|
|
||||||
}
|
}
|
||||||
|
return productTree;
|
||||||
toggle.addEventListener('click', event => {
|
|
||||||
event.stopPropagation();
|
|
||||||
childrenContainer.classList.toggle('collapsed');
|
|
||||||
toggle.textContent = childrenContainer.classList.contains('collapsed') ? '▶' : '▼';
|
|
||||||
saveTreeState();
|
|
||||||
});
|
|
||||||
|
|
||||||
container.appendChild(toggle);
|
|
||||||
container.appendChild(label);
|
|
||||||
container.appendChild(childrenContainer);
|
|
||||||
return container;
|
|
||||||
}
|
|
||||||
|
|
||||||
const label = document.createElement('label');
|
|
||||||
if (hasFiles) {
|
|
||||||
const checkbox = createCheckbox(path, value);
|
|
||||||
label.appendChild(checkbox);
|
|
||||||
}
|
|
||||||
|
|
||||||
label.appendChild(document.createTextNode(name));
|
|
||||||
container.appendChild(label);
|
|
||||||
return container;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function shouldStartCollapsed(value) {
|
function toTreeNodes(value, path = []) {
|
||||||
if (!value || typeof value !== 'object') {
|
return getSortedKeys(value)
|
||||||
return false;
|
.filter(key => key !== 'addressed' && key !== 'known')
|
||||||
}
|
.map(key => {
|
||||||
|
const childValue = value[key];
|
||||||
|
const childPath = [...path, key];
|
||||||
|
const isObject = childValue && typeof childValue === 'object';
|
||||||
|
const hasFiles = isObject && ('addressed' in childValue || 'known' in childValue);
|
||||||
|
const children = isObject ? toTreeNodes(childValue, childPath) : [];
|
||||||
|
|
||||||
const childKeys = Object.keys(value).filter(key => key !== 'addressed' && key !== 'known');
|
return {
|
||||||
return childKeys.length > 0;
|
id: JSON.stringify(childPath),
|
||||||
|
label: key,
|
||||||
|
selectable: hasFiles || children.length > 0,
|
||||||
|
metadata: {
|
||||||
|
path: childPath,
|
||||||
|
addressed: hasFiles && Array.isArray(childValue.addressed) ? childValue.addressed : [],
|
||||||
|
known: hasFiles && Array.isArray(childValue.known) ? childValue.known : []
|
||||||
|
},
|
||||||
|
...(children.length > 0 ? { children } : {})
|
||||||
|
};
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function dedupeFileRefs(fileRefs) {
|
function dedupeFileRefs(fileRefs) {
|
||||||
const seen = new Set();
|
const seen = new Set();
|
||||||
return fileRefs.filter(ref => {
|
return fileRefs.filter(({ productKey, fileName }) => {
|
||||||
const productKey = String(ref && ref.productKey ? ref.productKey : '');
|
|
||||||
const fileName = String(ref && ref.fileName ? ref.fileName : '');
|
|
||||||
if (!productKey || !fileName) {
|
if (!productKey || !fileName) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
const key = `${productKey}::${fileName}`;
|
const key = `${productKey}::${fileName}`;
|
||||||
if (seen.has(key)) {
|
if (seen.has(key)) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
seen.add(key);
|
seen.add(key);
|
||||||
return true;
|
return true;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function createCheckbox(path, value) {
|
|
||||||
const checkbox = document.createElement('input');
|
|
||||||
checkbox.type = 'checkbox';
|
|
||||||
checkbox.value = path.join('/');
|
|
||||||
checkbox.dataset.path = JSON.stringify(path);
|
|
||||||
|
|
||||||
if (value && typeof value === 'object' && ('addressed' in value || 'known' in value)) {
|
|
||||||
checkbox.dataset.hasFiles = '1';
|
|
||||||
checkbox.dataset.addressed = JSON.stringify(Array.isArray(value.addressed) ? value.addressed : []);
|
|
||||||
checkbox.dataset.known = JSON.stringify(Array.isArray(value.known) ? value.known : []);
|
|
||||||
}
|
|
||||||
|
|
||||||
checkbox.addEventListener('change', event => {
|
|
||||||
handleCheckboxChange(event);
|
|
||||||
});
|
|
||||||
|
|
||||||
return checkbox;
|
|
||||||
}
|
|
||||||
|
|
||||||
function parseJsonArray(value) {
|
|
||||||
if (!value) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const parsed = JSON.parse(value);
|
|
||||||
return Array.isArray(parsed) ? parsed : [];
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Invalid checkbox file metadata:', value, error);
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleCheckboxChange(event) {
|
|
||||||
const checkbox = event.target;
|
|
||||||
const container = checkbox.closest('.tree-item');
|
|
||||||
const childrenContainer = container.querySelector('.tree-children');
|
|
||||||
|
|
||||||
if (childrenContainer) {
|
|
||||||
const childCheckboxes = childrenContainer.querySelectorAll('input[type="checkbox"]');
|
|
||||||
childCheckboxes.forEach(cb => {
|
|
||||||
cb.checked = checkbox.checked;
|
|
||||||
cb.indeterminate = false;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
checkbox.indeterminate = false;
|
|
||||||
updateParentCheckboxes(checkbox);
|
|
||||||
|
|
||||||
if (typeof onSelectionChangeHandler === 'function') {
|
|
||||||
onSelectionChangeHandler();
|
|
||||||
}
|
|
||||||
saveTreeState();
|
|
||||||
}
|
|
||||||
|
|
||||||
function updateParentCheckboxes(checkbox) {
|
|
||||||
let currentItem = checkbox.closest('.tree-item');
|
|
||||||
let parentItem = currentItem.parentElement.closest('.tree-item');
|
|
||||||
|
|
||||||
while (parentItem) {
|
|
||||||
const parentLabel = parentItem.querySelector(':scope > label');
|
|
||||||
if (!parentLabel) {
|
|
||||||
parentItem = parentItem.parentElement.closest('.tree-item');
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
const parentCheckbox = parentLabel.querySelector('input[type="checkbox"]');
|
|
||||||
if (!parentCheckbox) {
|
|
||||||
parentItem = parentItem.parentElement.closest('.tree-item');
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
const children = parentItem.querySelectorAll(':scope > .tree-children > .tree-item > label > input[type="checkbox"]');
|
|
||||||
const checkedCount = Array.from(children).filter(cb => cb.checked || cb.indeterminate).length;
|
|
||||||
|
|
||||||
if (checkedCount === 0) {
|
|
||||||
parentCheckbox.checked = false;
|
|
||||||
parentCheckbox.indeterminate = false;
|
|
||||||
} else if (checkedCount === children.length) {
|
|
||||||
parentCheckbox.checked = true;
|
|
||||||
parentCheckbox.indeterminate = false;
|
|
||||||
} else {
|
|
||||||
parentCheckbox.checked = false;
|
|
||||||
parentCheckbox.indeterminate = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
currentItem = parentItem;
|
|
||||||
parentItem = currentItem.parentElement.closest('.tree-item');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function saveTreeState() {
|
|
||||||
if (!treeContainer) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const checkedPaths = Array.from(
|
|
||||||
treeContainer.querySelectorAll('input[type="checkbox"]:checked[data-has-files="1"]')
|
|
||||||
).map(cb => cb.dataset.path).filter(Boolean);
|
|
||||||
|
|
||||||
const collapsedPaths = Array.from(
|
|
||||||
treeContainer.querySelectorAll('.tree-children.collapsed')
|
|
||||||
).map(children => {
|
|
||||||
const cb = children.parentElement.querySelector(':scope > label > input[type="checkbox"]');
|
|
||||||
return cb?.dataset.path ?? null;
|
|
||||||
}).filter(Boolean);
|
|
||||||
|
|
||||||
try {
|
|
||||||
localStorage.setItem(STORAGE_KEY, JSON.stringify({ checkedPaths, collapsedPaths }));
|
|
||||||
} catch {
|
|
||||||
// Storage unavailable or quota exceeded — silently ignore
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function restoreTreeState() {
|
|
||||||
if (!treeContainer) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
let state;
|
|
||||||
try {
|
|
||||||
const raw = localStorage.getItem(STORAGE_KEY);
|
|
||||||
if (!raw) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
state = JSON.parse(raw);
|
|
||||||
} catch {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!state || typeof state !== 'object') {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (Array.isArray(state.checkedPaths) && state.checkedPaths.length > 0) {
|
|
||||||
const checkedSet = new Set(state.checkedPaths);
|
|
||||||
treeContainer.querySelectorAll('input[type="checkbox"][data-has-files="1"]').forEach(cb => {
|
|
||||||
cb.checked = checkedSet.has(cb.dataset.path);
|
|
||||||
});
|
|
||||||
treeContainer.querySelectorAll('input[type="checkbox"][data-has-files="1"]:checked').forEach(cb => {
|
|
||||||
updateParentCheckboxes(cb);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (Array.isArray(state.collapsedPaths)) {
|
|
||||||
const collapsedSet = new Set(state.collapsedPaths);
|
|
||||||
treeContainer.querySelectorAll('.tree-children').forEach(childrenDiv => {
|
|
||||||
const cb = childrenDiv.parentElement.querySelector(':scope > label > input[type="checkbox"]');
|
|
||||||
if (!cb) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const toggle = childrenDiv.parentElement.querySelector(':scope > .tree-toggle');
|
|
||||||
const shouldBeCollapsed = collapsedSet.has(cb.dataset.path);
|
|
||||||
const isCollapsed = childrenDiv.classList.contains('collapsed');
|
|
||||||
if (shouldBeCollapsed !== isCollapsed) {
|
|
||||||
childrenDiv.classList.toggle('collapsed');
|
|
||||||
if (toggle) {
|
|
||||||
toggle.textContent = shouldBeCollapsed ? '▶' : '▼';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -0,0 +1,47 @@
|
|||||||
|
# CheckboxTree
|
||||||
|
|
||||||
|
A dependency-free, instance-based checkbox tree for browser ES modules.
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
```html
|
||||||
|
<link rel="stylesheet" href="./checkbox-tree.css">
|
||||||
|
<div id="example-tree"></div>
|
||||||
|
<script type="module">
|
||||||
|
import { CheckboxTree } from './checkbox-tree.js';
|
||||||
|
|
||||||
|
const tree = new CheckboxTree(document.querySelector('#example-tree'), {
|
||||||
|
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' } }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]);
|
||||||
|
```
|
||||||
|
|
||||||
|
Every node requires a unique string `id` and a string `label`. 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 a new node array, then restores saved state.
|
||||||
|
- `getSelectedIds()` returns selected node IDs.
|
||||||
|
- `getSelectedNodes()` returns the selected source node objects.
|
||||||
|
- `setSelectedIds(ids, { notify })` replaces the current selection.
|
||||||
|
- `restoreState()` restores selection and expansion when `storageKey` is configured.
|
||||||
|
- `destroy()` removes listeners, markup, and the root CSS class.
|
||||||
|
|
||||||
|
Options are `initiallyCollapsed` (default `true`), `storageKey` (default `null`),
|
||||||
|
and `onSelectionChange`. Styling is namespaced under `.checkbox-tree` and its
|
||||||
|
custom properties can be overridden by a consuming application.
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
.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: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,287 @@
|
|||||||
|
const DEFAULT_OPTIONS = {
|
||||||
|
initiallyCollapsed: true,
|
||||||
|
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.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;
|
||||||
|
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}`);
|
||||||
|
toggle.textContent = expanded ? '▼' : '▶';
|
||||||
|
}
|
||||||
|
|
||||||
|
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));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+2
-77
@@ -107,81 +107,6 @@ h2 {
|
|||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
#product-tree {
|
|
||||||
font-size: 14px;
|
|
||||||
line-height: 1.4;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tree-item {
|
|
||||||
margin: 1px 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tree-item > label {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
padding: 2px 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Keep label text aligned whether or not a node has a toggle icon. */
|
|
||||||
.tree-item:not(.parent) > label {
|
|
||||||
margin-left: 18px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tree-item.parent > label {
|
|
||||||
font-weight: 500;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tree-toggle {
|
|
||||||
cursor: pointer;
|
|
||||||
user-select: none;
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
width: 16px;
|
|
||||||
height: 1.4em;
|
|
||||||
margin-right: 2px;
|
|
||||||
flex-shrink: 0;
|
|
||||||
vertical-align: middle;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tree-children {
|
|
||||||
margin-left: 16px;
|
|
||||||
overflow: hidden;
|
|
||||||
transition: max-height 0.3s ease, opacity 0.3s ease;
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tree-children .tree-children {
|
|
||||||
margin-left: 33px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tree-children.collapsed {
|
|
||||||
max-height: 0;
|
|
||||||
opacity: 0;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tree-item input[type="checkbox"] {
|
|
||||||
cursor: pointer;
|
|
||||||
margin-right: 4px;
|
|
||||||
flex-shrink: 0;
|
|
||||||
width: 16px;
|
|
||||||
height: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tree-item input[type="checkbox"]:indeterminate {
|
|
||||||
accent-color: #f39c12;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tree-item label {
|
|
||||||
cursor: pointer;
|
|
||||||
user-select: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tree-item label:hover {
|
|
||||||
color: #2c3e50;
|
|
||||||
}
|
|
||||||
|
|
||||||
#issues {
|
#issues {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -298,8 +223,8 @@ li:hover {
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tree-children {
|
.checkbox-tree {
|
||||||
margin-left: 12px;
|
--checkbox-tree-indent: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.issues-table {
|
.issues-table {
|
||||||
|
|||||||
Reference in New Issue
Block a user