diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..96d3c81 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,3 @@ +{ + "liveServer.settings.root": "/web" +} diff --git a/test/checkbox-tree.test.mjs b/test/checkbox-tree.test.mjs new file mode 100644 index 0000000..7ad0a04 --- /dev/null +++ b/test/checkbox-tree.test.mjs @@ -0,0 +1,100 @@ +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('../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/ + ); +}); diff --git a/web/index.html b/web/index.html index 024b8fc..f6a5e36 100644 --- a/web/index.html +++ b/web/index.html @@ -8,6 +8,7 @@ + @@ -55,4 +56,4 @@ - \ No newline at end of file + diff --git a/web/js/tree.js b/web/js/tree.js index c00aed5..141363a 100644 --- a/web/js/tree.js +++ b/web/js/tree.js @@ -1,85 +1,39 @@ +import { CheckboxTree } from '../lib/checkbox-tree/checkbox-tree.js'; import { getSortedKeys } from './sort.js'; -const STORAGE_KEY = 'tree-state'; - -let treeContainer = null; -let onSelectionChangeHandler = null; +const STORAGE_KEY = 'tree-state-v2'; +let productTree = null; export function initializeTree(containerId, onSelectionChange) { - treeContainer = document.getElementById(containerId); - onSelectionChangeHandler = onSelectionChange; -} - -export function renderProductTree(productsData) { - if (!treeContainer) { - throw new Error('Tree is not initialized. Call initializeTree first.'); + const container = document.getElementById(containerId); + if (!container) { + throw new Error(`Tree container not found: ${containerId}`); } - treeContainer.innerHTML = ''; - - getSortedKeys(productsData).forEach(product => { - const productNode = createTreeNode(product, productsData[product], [product]); - treeContainer.appendChild(productNode); + productTree?.destroy(); + productTree = new CheckboxTree(container, { + storageKey: STORAGE_KEY, + initiallyCollapsed: true, + onSelectionChange }); } +export function renderProductTree(productsData) { + requireTree().setData(toTreeNodes(productsData)); +} + export function getCheckedPaths() { - if (!treeContainer) { - 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); + return productTree ? productTree.getSelectedNodes().map(node => node.metadata.path) : []; } export function getCheckedFileRefs() { - if (!treeContainer) { - return { - addressedFiles: [], - knownFiles: [] - }; - } - const addressedFiles = []; 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 addressed = parseJsonArray(cb.dataset.addressed); addressed.forEach(fileName => addressedFiles.push({ productKey, fileName })); - - const known = parseJsonArray(cb.dataset.known); known.forEach(fileName => knownFiles.push({ productKey, fileName })); }); @@ -89,255 +43,51 @@ export function getCheckedFileRefs() { }; } -function createTreeNode(name, value, path) { - const container = document.createElement('div'); - container.className = 'tree-item'; +// State restoration now happens when setData renders the generic tree. +export function restoreTreeState() {} - const isObjectValue = value && typeof value === 'object'; - const hasFiles = isObjectValue && ('addressed' in value || 'known' in value); - const childKeys = isObjectValue - ? 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 = '▶'; - } - - 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; +function requireTree() { + if (!productTree) { + throw new Error('Tree is not initialized. Call initializeTree first.'); } - - 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; + return productTree; } -function shouldStartCollapsed(value) { - if (!value || typeof value !== 'object') { - return false; - } +function toTreeNodes(value, path = []) { + return getSortedKeys(value) + .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 childKeys.length > 0; + return { + 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) { const seen = new Set(); - return fileRefs.filter(ref => { - const productKey = String(ref && ref.productKey ? ref.productKey : ''); - const fileName = String(ref && ref.fileName ? ref.fileName : ''); + return fileRefs.filter(({ productKey, fileName }) => { if (!productKey || !fileName) { return false; } - const key = `${productKey}::${fileName}`; if (seen.has(key)) { return false; } - seen.add(key); 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 ? '▶' : '▼'; - } - } - }); - } -} diff --git a/web/lib/checkbox-tree/README.md b/web/lib/checkbox-tree/README.md new file mode 100644 index 0000000..441aaf1 --- /dev/null +++ b/web/lib/checkbox-tree/README.md @@ -0,0 +1,47 @@ +# CheckboxTree + +A dependency-free, instance-based checkbox tree for browser ES modules. + +## Usage + +```html + +
+