Files
a-plot-of-plants/vendor/checkbox-tree/checkbox-tree.js
T

619 lines
22 KiB
JavaScript

const DEFAULT_OPTIONS = {
initiallyCollapsed: true,
initiallySelected: false,
storageKey: null,
onSelectionChange: null,
manifest: null
};
const SERIALIZATION_VERSION = 1;
const PATH_DELIMITER = '>';
export function loadManifest(source) {
let records;
let schema = 1;
if (typeof source === 'string') {
records = source.split(/\r?\n/)
.map(line => line.trim())
.filter(line => line && !line.startsWith('#'))
.map(line => line === '!deleted' ? null : line);
} else if (source && typeof source === 'object' && Array.isArray(source.nodes)) {
schema = source.schema;
records = source.nodes;
} else if (source && Array.isArray(source.slots)) {
return normalizeManifest(source.schema, source.slots.map(slot => slot?.deleted ? null : slot?.path));
} else {
throw new TypeError('CheckboxTree manifest must be text or an object with a nodes array.');
}
return normalizeManifest(schema, records);
}
function normalizeManifest(schema, records) {
if (schema !== 1) {
throw new Error(`Unsupported CheckboxTree manifest schema: ${schema}`);
}
const pathToSlot = new Map();
const slots = records.map((record, index) => {
if (record === null || record === '!deleted') {
return { path: null, deleted: true };
}
if (typeof record !== 'string' || !record || record.trim() !== record) {
throw new TypeError(`Invalid CheckboxTree manifest entry at slot ${index}.`);
}
if (pathToSlot.has(record)) {
throw new Error(`Duplicate CheckboxTree manifest path: ${record}`);
}
pathToSlot.set(record, index);
return { path: record, deleted: false };
});
return { schema: 1, slots, pathToSlot };
}
export function setBit(bytes, bitIndex, value) {
const byteIndex = Math.floor(bitIndex / 8);
const mask = 1 << (bitIndex % 8);
if (value) bytes[byteIndex] |= mask;
else bytes[byteIndex] &= ~mask;
}
export function getBit(bytes, bitIndex) {
const byteIndex = Math.floor(bitIndex / 8);
return byteIndex < bytes.length && (bytes[byteIndex] & (1 << (bitIndex % 8))) !== 0;
}
export function bytesToBase64Url(bytes) {
let binary = '';
for (const byte of bytes) binary += String.fromCharCode(byte);
return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/g, '');
}
export function base64UrlToBytes(value, maximumLength = Infinity) {
if (typeof value !== 'string' || !/^[A-Za-z0-9_-]*$/.test(value) || value.length % 4 === 1) {
throw new Error('Invalid Base64URL value.');
}
if (Number.isFinite(maximumLength) && value.length > Math.ceil(maximumLength * 4 / 3) + 2) {
throw new Error('Encoded tree state exceeds the manifest size.');
}
const base64 = value.replace(/-/g, '+').replace(/_/g, '/');
const binary = atob(base64 + '='.repeat((4 - base64.length % 4) % 4));
if (binary.length > maximumLength) throw new Error('Encoded tree state exceeds the manifest size.');
return Uint8Array.from(binary, character => character.charCodeAt(0));
}
function trimTrailingZeroBytes(bytes) {
let end = bytes.length;
while (end && bytes[end - 1] === 0) end--;
return bytes.slice(0, end);
}
function encodeUnsignedVarint(value) {
const bytes = [];
do {
let byte = value & 0x7f;
value = Math.floor(value / 128);
if (value) byte |= 0x80;
bytes.push(byte);
} while (value);
return bytes;
}
function decodeUnsignedVarint(bytes, offset) {
let value = 0;
let multiplier = 1;
for (let index = offset; index < bytes.length && index < offset + 8; index++) {
const byte = bytes[index];
value += (byte & 0x7f) * multiplier;
if ((byte & 0x80) === 0) return { value, offset: index + 1 };
multiplier *= 128;
if (!Number.isSafeInteger(value) || !Number.isSafeInteger(multiplier)) break;
}
throw new Error('Invalid adaptive tree-state integer.');
}
function encodeSparseIndexes(indexes) {
const bytes = [];
let previous = -1;
for (const index of indexes) {
bytes.push(...encodeUnsignedVarint(index - previous - 1));
previous = index;
}
return bytes;
}
function decodeSparseIndexes(bytes, offset, limit) {
const indexes = new Set();
let previous = -1;
while (offset < bytes.length) {
const decoded = decodeUnsignedVarint(bytes, offset);
const index = previous + decoded.value + 1;
if (index >= limit) throw new Error('Sparse tree state contains an out-of-range slot.');
indexes.add(index);
previous = index;
offset = decoded.offset;
}
return indexes;
}
export function encodeAdaptiveBitset(values, slotCount) {
const enabled = [];
const disabled = [];
const denseBytes = new Uint8Array(Math.ceil(slotCount / 8));
for (const [slot, value] of values) {
(value ? enabled : disabled).push(slot);
setBit(denseBytes, slot, value);
}
enabled.sort((left, right) => left - right);
disabled.sort((left, right) => left - right);
if (enabled.length === 0) return new Uint8Array();
const candidates = [
Uint8Array.from([0, ...trimTrailingZeroBytes(denseBytes)]),
Uint8Array.from([1, ...encodeSparseIndexes(enabled)]),
Uint8Array.from([2, ...encodeUnsignedVarint(slotCount), ...encodeSparseIndexes(disabled)])
];
return candidates.reduce((shortest, candidate) =>
candidate.length < shortest.length ? candidate : shortest
);
}
export function decodeAdaptiveBitset(bytes, slotCount) {
if (bytes.length === 0) return () => false;
const mode = bytes[0];
if (mode === 0) {
const dense = bytes.slice(1);
if (dense.length > Math.ceil(slotCount / 8)) {
throw new Error('Dense tree state exceeds the manifest size.');
}
return slot => getBit(dense, slot);
}
if (mode === 1) {
const enabled = decodeSparseIndexes(bytes, 1, slotCount);
return slot => enabled.has(slot);
}
if (mode === 2) {
const coverage = decodeUnsignedVarint(bytes, 1);
if (coverage.value > slotCount) throw new Error('Tree-state coverage exceeds the manifest size.');
const disabled = decodeSparseIndexes(bytes, coverage.offset, coverage.value);
return slot => slot < coverage.value && !disabled.has(slot);
}
throw new Error('Unknown adaptive tree-state mode.');
}
function asManifest(manifest) {
return manifest?.pathToSlot instanceof Map ? manifest : loadManifest(manifest);
}
export function validateManifest(manifest, tree) {
const normalized = asManifest(manifest);
const treePaths = new Set(tree.nodesByPath.keys());
return {
valid: Array.from(treePaths).every(path => normalized.pathToSlot.has(path)),
unregisteredPaths: Array.from(treePaths).filter(path => !normalized.pathToSlot.has(path)),
missingPaths: normalized.slots
.filter(slot => !slot.deleted && !treePaths.has(slot.path))
.map(slot => slot.path)
};
}
export function validateManifestEvolution(previous, next) {
const oldManifest = asManifest(previous);
const newManifest = asManifest(next);
const errors = [];
if (newManifest.slots.length < oldManifest.slots.length) {
errors.push('Manifest slots cannot be removed; use tombstones.');
}
oldManifest.slots.forEach((oldSlot, index) => {
const newSlot = newManifest.slots[index];
if (!newSlot) return;
if (oldSlot.deleted && !newSlot.deleted) {
errors.push(`Tombstoned slot ${index} cannot be reused.`);
}
if (!oldSlot.deleted && !newSlot.deleted && oldSlot.path !== newSlot.path) {
errors.push(`Slot ${index} changed path; confirm this is a logical rename or move.`);
}
});
return { valid: errors.length === 0, errors };
}
export function encodeTreeState(tree, manifest) {
const normalized = asManifest(manifest);
const checkedValues = new Map();
const expandedValues = new Map();
for (const [path, node] of tree.nodesByPath) {
const slot = normalized.pathToSlot.get(path);
if (slot === undefined) continue;
const item = tree.itemForNode(node.id);
const checkbox = item && tree.directCheckbox(item);
const toggle = item?.querySelector(':scope > .checkbox-tree__row > .checkbox-tree__toggle');
checkedValues.set(slot, Boolean(checkbox?.checked));
expandedValues.set(slot, toggle?.getAttribute('aria-expanded') === 'true');
}
const checkedValue = bytesToBase64Url(encodeAdaptiveBitset(checkedValues, normalized.slots.length));
const expandedValue = bytesToBase64Url(encodeAdaptiveBitset(expandedValues, normalized.slots.length));
return { version: SERIALIZATION_VERSION, checked: checkedValue || null, expanded: expandedValue || null };
}
export function decodeTreeState(fragment, manifest) {
const normalized = asManifest(manifest);
const value = fragment.startsWith('#') ? fragment.slice(1) : fragment;
const params = new URLSearchParams(value);
if (params.get('v') !== String(SERIALIZATION_VERSION)) {
return { applied: false, version: params.get('v'), warnings: ['Missing or unsupported serialization version.'] };
}
const maximumLength = Math.max(
Math.ceil(normalized.slots.length / 8) + 1,
normalized.slots.length * 2 + 10
);
try {
const checked = base64UrlToBytes(params.get('c') ?? '', maximumLength);
const expanded = base64UrlToBytes(params.get('x') ?? '', maximumLength);
decodeAdaptiveBitset(checked, normalized.slots.length);
decodeAdaptiveBitset(expanded, normalized.slots.length);
return {
applied: true,
version: SERIALIZATION_VERSION,
checked,
expanded,
warnings: []
};
} catch (error) {
return { applied: false, version: SERIALIZATION_VERSION, warnings: [error.message] };
}
}
export function applyTreeState(tree, manifest, state) {
if (!state?.applied) return false;
const normalized = asManifest(manifest);
let isChecked;
let isExpanded;
try {
isChecked = decodeAdaptiveBitset(state.checked, normalized.slots.length);
isExpanded = decodeAdaptiveBitset(state.expanded, normalized.slots.length);
} catch {
return false;
}
for (const [path, node] of tree.nodesByPath) {
const slot = normalized.pathToSlot.get(path);
if (slot === undefined) continue;
const item = tree.itemForNode(node.id);
const checkbox = item && tree.directCheckbox(item);
if (checkbox && !checkbox.disabled) checkbox.checked = isChecked(slot);
tree.setExpanded(item, isExpanded(slot));
}
tree.updateAllParentCheckboxes();
return true;
}
export function serializeStateToFragment(tree, manifest) {
const state = encodeTreeState(tree, manifest);
const params = new URLSearchParams({ v: String(state.version) });
if (state.checked) params.set('c', state.checked);
if (state.expanded) params.set('x', state.expanded);
return `#${params}`;
}
export function restoreStateFromLocation(tree, manifest, location = window.location) {
const state = decodeTreeState(location.hash ?? '', manifest);
if (state.applied) applyTreeState(tree, manifest, state);
return state;
}
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.nodesByPath = 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.nodesByPath.clear();
this.rootNodes = nodes;
this.indexNodes(nodes);
this.render();
this.updateAllParentCheckboxes();
this.restoreState();
if (this.options.manifest) this.restoreStateFromLocation();
}
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();
this.nodesByPath.clear();
}
indexNodes(nodes, parentPath = '') {
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}`);
}
if (node.id.includes(PATH_DELIMITER)) {
throw new Error(`CheckboxTree node id cannot contain "${PATH_DELIMITER}": ${node.id}`);
}
this.nodesById.set(node.id, node);
const path = parentPath ? `${parentPath}${PATH_DELIMITER}${node.id}` : node.id;
this.nodesByPath.set(path, 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, path);
}
});
}
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) {
if (!item) return;
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');
}
itemForNode(id) {
return Array.from(this.container.querySelectorAll('.checkbox-tree__item'))
.find(item => item.dataset.nodeId === id) ?? null;
}
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));
}
});
}
}
validateManifest(manifest = this.options.manifest) {
return validateManifest(manifest, this);
}
serializeStateToFragment(manifest = this.options.manifest) {
return serializeStateToFragment(this, manifest);
}
restoreStateFromLocation(manifest = this.options.manifest, location = window.location) {
return restoreStateFromLocation(this, manifest, location);
}
}