blob: bba4f728b163cf0974ef7e87705a3b0605134d82 [file]
/**
* TreeTable — lightweight hierarchical table library
*
* Copyright (c) Qualcomm Technologies, Inc. and/or its subsidiaries.
*
* SPDX-License-Identifier: Apache-2.0
*
* Usage:
* const tt = TreeTable('tableId', options);
* const tt = TreeTable(tableElement, options);
*
* Options:
* data {Array} Required. Array of root node objects.
* columns {Array} Required. Array of column definition objects.
* indentSize {number} Optional. Pixels per depth level. Default: 20.
* icons {Object} Optional. Icon configuration object:
* icons.expand {string} CSS class(es) for the collapsed-parent toggle icon.
* icons.collapse {string} CSS class(es) for the expanded-parent toggle icon.
* icons.node {Function} (node, state) => cssClassString | null
* onExpand {Function} Optional. (node) => void
* onCollapse {Function} Optional. (node) => void
* onSelect {Function} Optional. (node) => void — fired when a row is selected.
* onRowCreated {Function} Optional. (node, tr) => void — fired after each row is appended to the table.
*
* Node format:
* { id, data, children, expanded }
*
* Column definition format:
* { render: (node) => string|Element, className: 'optional-class' }
*
* Public API (returned object):
* tt.select(id) — programmatically select the row with the given id.
* tt.deselect() — deselect the currently selected row (if any).
* tt.getSelected() — return the currently selected node object, or null.
* tt.expand(id) — expand the node with the given id.
* tt.collapse(id) — collapse the node with the given id.
* tt.expandAll() — expand all nodes that have children.
* tt.collapseAll() — collapse all nodes that have children.
* tt.filter(str) — filter rows by string; the table gets tt-filtered and
* matching rows get tt-filter-match. Ancestors of matching
* rows are auto-expanded and re-collapsed when no longer needed.
* Pass '' or null to clear the filter.
*/
(function (global) {
'use strict';
/**
* Factory function — creates and initialises a TreeTable instance.
*
* @param {string|Element} tableId - The id of the <table> element, or the element itself.
* @param {Object} options - Configuration object (see above).
* @returns {Object} - Public API object.
*/
function TreeTable(tableId, options) {
// ------------------------------------------------------------------ //
// Validate inputs
// ------------------------------------------------------------------ //
var table = typeof tableId === 'string' ? document.getElementById(tableId) : tableId;
if (!table || !(table instanceof Element)) {
throw new Error('TreeTable: first argument must be a table element id string or a DOM Element');
}
var opts = options || {};
var data = opts.data || [];
var columns = opts.columns || [];
var indentSize = typeof opts.indentSize === 'number' ? opts.indentSize : 20;
var icons = opts.icons && typeof opts.icons === 'object' ? opts.icons : {};
var iconExpandCls = typeof icons.expand === 'string' && icons.expand.trim() !== '' ? icons.expand.trim() : null;
var iconCollapseCls = typeof icons.collapse === 'string' && icons.collapse.trim() !== '' ? icons.collapse.trim() : null;
var iconClassFn = typeof icons.node === 'function' ? icons.node : null;
var onExpand = typeof opts.onExpand === 'function' ? opts.onExpand : null;
var onCollapse = typeof opts.onCollapse === 'function' ? opts.onCollapse : null;
var onSelect = typeof opts.onSelect === 'function' ? opts.onSelect : null;
var onRowCreated = typeof opts.onRowCreated === 'function' ? opts.onRowCreated : null;
if (columns.length === 0) {
throw new Error('TreeTable: "columns" must be a non-empty array');
}
// ------------------------------------------------------------------ //
// Internal state
// nodeMap: { [id]: { node, expanded, tr, depth, parentId } }
// selectedId: id of the currently selected row (or null)
// autoExpandedIds: plain-object set of ids expanded silently by filter()
// ------------------------------------------------------------------ //
var nodeMap = {};
var selectedId = null;
var autoExpandedIds = {};
// ------------------------------------------------------------------ //
// Locate or create <tbody>
// ------------------------------------------------------------------ //
var tbody = table.querySelector('tbody');
if (!tbody) {
tbody = document.createElement('tbody');
table.appendChild(tbody);
}
// Clear any existing rows
tbody.innerHTML = '';
// ------------------------------------------------------------------ //
// Build the toggle icon element for a given state.
// state: 'expanded' | 'collapsed' | 'leaf'
// Uses icons.expand / icons.collapse CSS classes when provided,
// otherwise falls back to +/- text.
// ------------------------------------------------------------------ //
function buildToggleIcon(state) {
var icon = document.createElement('span');
icon.className = 'tt-icon';
if (state === 'collapsed') {
if (iconExpandCls) {
iconExpandCls.split(/\s+/).forEach(function (c) { icon.classList.add(c); });
} else {
icon.textContent = '+';
}
} else if (state === 'expanded') {
if (iconCollapseCls) {
iconCollapseCls.split(/\s+/).forEach(function (c) { icon.classList.add(c); });
} else {
icon.textContent = '-';
}
} else {
// leaf — blank, no pointer cursor
icon.classList.add('tt-icon-leaf');
}
return icon;
}
// ------------------------------------------------------------------ //
// Build the user-supplied icon element (tt-user-icon) for a given node
// and state. Returns null if no iconClassFn is set or it returns nothing.
// ------------------------------------------------------------------ //
function buildUserIcon(node, state) {
if (!iconClassFn) return null;
var cls = iconClassFn(node, state);
if (!cls || typeof cls !== 'string' || cls.trim() === '') return null;
var icon = document.createElement('span');
icon.className = 'tt-user-icon';
cls.trim().split(/\s+/).forEach(function (c) {
icon.classList.add(c);
});
return icon;
}
// ------------------------------------------------------------------ //
// Update the toggle icon and user icon inside a row's first cell.
// DOM order: [indent] [tt-icon] [tt-user-icon?] [tt-cell-content]
// ------------------------------------------------------------------ //
function updateIcon(entry) {
var td = entry.tr.cells[0];
var hasChildren = entry.node.children && entry.node.children.length > 0;
var state = hasChildren ? (entry.expanded ? 'expanded' : 'collapsed') : 'leaf';
// --- Replace toggle icon ---
var oldToggle = td.querySelector('.tt-icon');
var newToggle = buildToggleIcon(state);
if (hasChildren) {
newToggle.addEventListener('click', function () {
toggleNode(entry.node.id);
});
}
td.replaceChild(newToggle, oldToggle);
// --- Replace or insert user icon ---
var oldUserIcon = td.querySelector('.tt-user-icon');
var newUserIcon = buildUserIcon(entry.node, state);
if (oldUserIcon) {
if (newUserIcon) {
td.replaceChild(newUserIcon, oldUserIcon);
} else {
td.removeChild(oldUserIcon);
}
} else if (newUserIcon) {
// Insert after the toggle icon, before tt-cell-content
var content = td.querySelector('.tt-cell-content');
td.insertBefore(newUserIcon, content);
}
}
// ------------------------------------------------------------------ //
// Determine whether a row should be visible:
// visible if all ancestors are expanded
// ------------------------------------------------------------------ //
function isVisible(entry) {
var parentId = entry.parentId;
while (parentId !== null && parentId !== undefined) {
var parentEntry = nodeMap[parentId];
if (!parentEntry) break;
if (!parentEntry.expanded) return false;
parentId = parentEntry.parentId;
}
return true;
}
// ------------------------------------------------------------------ //
// Show / hide direct children of a node, recursing into expanded ones
// ------------------------------------------------------------------ //
function setChildrenVisibility(parentId, visible) {
var parentEntry = nodeMap[parentId];
if (!parentEntry || !parentEntry.node.children) return;
parentEntry.node.children.forEach(function (child) {
var childEntry = nodeMap[child.id];
if (!childEntry) return;
if (visible) {
childEntry.tr.style.display = '';
// If this child is itself expanded, show its children too
if (childEntry.expanded && childEntry.node.children && childEntry.node.children.length > 0) {
setChildrenVisibility(child.id, true);
}
} else {
childEntry.tr.style.display = 'none';
// Recursively hide all descendants regardless of their expanded state
if (childEntry.node.children && childEntry.node.children.length > 0) {
setChildrenVisibility(child.id, false);
}
}
});
}
// ------------------------------------------------------------------ //
// Toggle expand/collapse for a node
// ------------------------------------------------------------------ //
function toggleNode(id) {
var entry = nodeMap[id];
if (!entry) return;
if (!entry.node.children || entry.node.children.length === 0) return;
if (entry.expanded) {
collapseNode(id);
} else {
expandNode(id);
}
}
// ------------------------------------------------------------------ //
// Expand / collapse a node silently (no onExpand / onCollapse callback).
// Used internally by filter() so user callbacks are not spammed.
// ------------------------------------------------------------------ //
function expandNodeSilent(id) {
var entry = nodeMap[id];
if (!entry) return;
if (!entry.node.children || entry.node.children.length === 0) return;
if (entry.expanded) return;
entry.expanded = true;
updateIcon(entry);
setChildrenVisibility(id, true);
}
function collapseNodeSilent(id) {
var entry = nodeMap[id];
if (!entry) return;
if (!entry.node.children || entry.node.children.length === 0) return;
if (!entry.expanded) return;
entry.expanded = false;
updateIcon(entry);
setChildrenVisibility(id, false);
}
// ------------------------------------------------------------------ //
// Return a plain-object set of all ancestor ids of the currently
// selected row. Used by filter() to avoid hiding the selected row
// when collapsing auto-expanded nodes.
// ------------------------------------------------------------------ //
function getSelectedAncestorIds() {
var result = {};
if (selectedId === null) return result;
var selEntry = nodeMap[selectedId];
if (!selEntry) return result;
var pid = selEntry.parentId;
while (pid !== null && pid !== undefined) {
result[pid] = true;
var pEntry = nodeMap[pid];
if (!pEntry) break;
pid = pEntry.parentId;
}
return result;
}
// ------------------------------------------------------------------ //
// Expand / collapse a single node (internal) — fires user callbacks.
// ------------------------------------------------------------------ //
function expandNode(id) {
expandNodeSilent(id);
var entry = nodeMap[id];
if (entry && entry.expanded && onExpand) onExpand(entry.node);
}
function collapseNode(id) {
collapseNodeSilent(id);
var entry = nodeMap[id];
if (entry && !entry.expanded && onCollapse) onCollapse(entry.node);
}
// ------------------------------------------------------------------ //
// Build a <td> for the first column (includes indent + icon + content)
// ------------------------------------------------------------------ //
function buildFirstCell(node, depth, colDef) {
var td = document.createElement('td');
if (colDef.className) td.className = colDef.className;
// Indent spacer
var indent = document.createElement('span');
indent.className = 'tt-indent';
indent.style.display = 'inline-block';
indent.style.width = (depth * indentSize) + 'px';
td.appendChild(indent);
// Icon placeholder — will be replaced by updateIcon after nodeMap entry exists
var iconPlaceholder = document.createElement('span');
iconPlaceholder.className = 'tt-icon';
td.appendChild(iconPlaceholder);
// Rendered content
var content = document.createElement('span');
content.className = 'tt-cell-content';
var rendered = colDef.render(node);
if (rendered instanceof Element || rendered instanceof DocumentFragment) {
content.appendChild(rendered);
} else if (colDef.htmlContent === true) {
content.innerHTML = rendered == null ? '' : String(rendered);
} else {
content.textContent = rendered == null ? '' : String(rendered);
}
td.appendChild(content);
return td;
}
// ------------------------------------------------------------------ //
// Build a <td> for columns 1..n
// ------------------------------------------------------------------ //
function buildCell(node, colDef) {
var td = document.createElement('td');
if (colDef.className) td.className = colDef.className;
var content = document.createElement('span');
content.className = 'tt-cell-content';
var rendered = colDef.render(node);
if (rendered instanceof Element || rendered instanceof DocumentFragment) {
content.appendChild(rendered);
} else if (colDef.htmlContent === true) {
content.innerHTML = rendered == null ? '' : String(rendered);
} else {
content.textContent = rendered == null ? '' : String(rendered);
}
td.appendChild(content);
return td;
}
// ------------------------------------------------------------------ //
// Ensure all ancestors of a node are expanded so the row is visible
// ------------------------------------------------------------------ //
function ensureVisible(id) {
var entry = nodeMap[id];
if (!entry) return;
var parentId = entry.parentId;
while (parentId !== null && parentId !== undefined) {
var parentEntry = nodeMap[parentId];
if (!parentEntry) break;
if (!parentEntry.expanded) {
expandNode(parentId);
}
parentId = parentEntry.parentId;
}
}
// ------------------------------------------------------------------ //
// Select a row by id (deselects the previously selected row)
// ------------------------------------------------------------------ //
function selectNode(id) {
// Make sure the row is visible before selecting it
ensureVisible(id);
// Deselect previous
if (selectedId !== null && selectedId !== id) {
var prevEntry = nodeMap[selectedId];
if (prevEntry) {
prevEntry.tr.classList.remove('tt-selected');
}
}
var entry = nodeMap[id];
if (!entry) return;
if (selectedId === id) {
// Clicking the already-selected row deselects it
entry.tr.classList.remove('tt-selected');
selectedId = null;
} else {
entry.tr.classList.add('tt-selected');
selectedId = id;
if (onSelect) onSelect(entry.node);
}
}
// ------------------------------------------------------------------ //
// Recursively walk the node tree and append <tr> elements to tbody
// autoIdCounter provides fallback ids for nodes that omit the id field.
// ------------------------------------------------------------------ //
var autoIdCounter = 0;
function walkNodes(nodes, depth, parentId) {
nodes.forEach(function (node) {
// Assign an automatic id if the node does not supply one
if (node.id == null || node.id === '') {
node.id = 'tt-auto-' + autoIdCounter;
}
autoIdCounter += 1;
var tr = document.createElement('tr');
tr.className = 'tt-row';
tr.setAttribute('data-tt-id', node.id);
tr.setAttribute('data-tt-depth', depth);
if (parentId !== null && parentId !== undefined) {
tr.setAttribute('data-tt-parent', parentId);
}
// Build cells
columns.forEach(function (colDef, colIndex) {
var td = colIndex === 0
? buildFirstCell(node, depth, colDef)
: buildCell(node, colDef);
tr.appendChild(td);
});
// Row click — select unless the click was on the toggle icon
tr.addEventListener('click', function (e) {
if (e.target.closest('.tt-icon') && !e.target.closest('.tt-icon.tt-icon-leaf')) {
return; // let the icon's own click handler handle expand/collapse
}
selectNode(node.id);
});
tbody.appendChild(tr);
if (onRowCreated) onRowCreated(node, tr);
// Register in nodeMap — warn if this id has already been seen
if (nodeMap[node.id] !== undefined) {
console.warn('TreeTable: duplicate node id "' + node.id + '" — only the first occurrence will be rendered correctly.');
}
var isExpanded = node.expanded === true;
nodeMap[node.id] = {
node: node,
expanded: isExpanded,
tr: tr,
depth: depth,
parentId: parentId !== undefined ? parentId : null
};
// Recurse into children
if (node.children && node.children.length > 0) {
walkNodes(node.children, depth + 1, node.id);
}
});
}
// ------------------------------------------------------------------ //
// Initialise: build all rows, then apply initial visibility
// ------------------------------------------------------------------ //
walkNodes(data, 0, null);
// Now that nodeMap is fully populated, set icons and initial visibility
Object.keys(nodeMap).forEach(function (id) {
var entry = nodeMap[id];
updateIcon(entry);
// Hide rows that should not be visible given initial expanded states
if (!isVisible(entry)) {
entry.tr.style.display = 'none';
}
});
// ------------------------------------------------------------------ //
// Public API
// ------------------------------------------------------------------ //
return {
/**
* Programmatically select the node with the given id.
* @param {string} id
*/
select: function (id) {
selectNode(id);
},
/**
* Deselect the currently selected row (if any).
*/
deselect: function () {
if (selectedId !== null) {
var entry = nodeMap[selectedId];
if (entry) entry.tr.classList.remove('tt-selected');
selectedId = null;
}
},
/**
* Return the currently selected node object, or null.
*/
getSelected: function () {
if (selectedId === null) return null;
var entry = nodeMap[selectedId];
return entry ? entry.node : null;
},
/**
* Expand the node with the given id.
* @param {string} id
*/
expand: function (id) {
expandNode(id);
},
/**
* Collapse the node with the given id.
* @param {string} id
*/
collapse: function (id) {
collapseNode(id);
},
/**
* Expand all nodes that have children.
*/
expandAll: function () {
Object.keys(nodeMap).forEach(function (id) {
var entry = nodeMap[id];
if (entry.node.children && entry.node.children.length > 0 && !entry.expanded) {
// Expand without firing callbacks for each — fire once at the end
entry.expanded = true;
updateIcon(entry);
}
});
// Show all rows
Object.keys(nodeMap).forEach(function (id) {
nodeMap[id].tr.style.display = '';
});
},
/**
* Filter rows by a string.
* The table receives the class tt-filtered while a filter is active.
* Rows whose cell text contains the string (case-insensitive) receive
* tt-filter-match; unmatched rows are styled via the CSS rule
* table.tt-filtered tr:not(.tt-filter-match).
* Ancestors of matching rows are expanded automatically; those expansions
* are tracked and reversed when the filter changes or is cleared.
* Pass an empty string or null to clear the filter entirely.
* @param {string|null} str
*/
filter: function (str) {
var term = str ? str.trim().toLowerCase() : '';
// Always clear previous filter state first
Object.keys(nodeMap).forEach(function (id) {
nodeMap[id].tr.classList.remove('tt-filter-match');
});
if (term === '') {
// Remove the filtered marker from the table
table.classList.remove('tt-filtered');
// Collapse auto-expanded nodes, but keep ancestors of the selected
// row expanded so the selected row remains visible.
var selAncestors = getSelectedAncestorIds();
Object.keys(autoExpandedIds).forEach(function (id) {
if (!selAncestors[id]) {
collapseNodeSilent(id);
delete autoExpandedIds[id];
}
});
return;
}
// Determine which nodes match
var matchingIds = {};
Object.keys(nodeMap).forEach(function (id) {
var entry = nodeMap[id];
var text = '';
var cells = entry.tr.querySelectorAll('.tt-cell-content');
cells.forEach(function (cell) { text += cell.textContent + ' '; });
if (text.toLowerCase().indexOf(term) !== -1) {
matchingIds[id] = true;
}
});
// Collect all ancestor ids that must be expanded to reveal matching rows
var neededAncestorIds = {};
Object.keys(matchingIds).forEach(function (id) {
var parentId = nodeMap[id].parentId;
while (parentId !== null && parentId !== undefined) {
neededAncestorIds[parentId] = true;
var parentEntry = nodeMap[parentId];
if (!parentEntry) break;
parentId = parentEntry.parentId;
}
});
// Collapse previously auto-expanded nodes that are no longer needed,
// but keep ancestors of the selected row expanded so it stays visible.
var selAncestors = getSelectedAncestorIds();
Object.keys(autoExpandedIds).forEach(function (id) {
if (!neededAncestorIds[id] && !selAncestors[id]) {
collapseNodeSilent(id);
delete autoExpandedIds[id];
}
});
// Expand needed ancestors that are not yet expanded
Object.keys(neededAncestorIds).forEach(function (id) {
var entry = nodeMap[id];
if (entry && !entry.expanded) {
expandNodeSilent(id);
autoExpandedIds[id] = true;
}
});
// Mark matching rows and activate the table-level filter marker
Object.keys(matchingIds).forEach(function (id) {
nodeMap[id].tr.classList.add('tt-filter-match');
});
table.classList.add('tt-filtered');
},
/**
* Collapse all nodes that have children.
*/
collapseAll: function () {
Object.keys(nodeMap).forEach(function (id) {
var entry = nodeMap[id];
if (entry.node.children && entry.node.children.length > 0 && entry.expanded) {
entry.expanded = false;
updateIcon(entry);
}
});
// Hide all non-root rows
Object.keys(nodeMap).forEach(function (id) {
var entry = nodeMap[id];
if (entry.depth > 0) {
entry.tr.style.display = 'none';
}
});
}
};
}
// Expose globally
global.TreeTable = TreeTable;
}(typeof window !== 'undefined' ? window : this));