Converting a hierarchical tree data structure to HTML table headings is really tricky. Becuase of the way that rowspan
and colspan
work, it’s not striaghtforwad to traverse the tree in one pass. Moreover, the row-oriented apporach is very different than column-oriented.
Created
August 28, 2018 07:10
-
-
Save jmakeig/ef9cc40534a1ce8538273a803ce7b999 to your computer and use it in GitHub Desktop.
Hierarchical table headers, both row and column orientations
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
body { | |
font-family: Helvetica, sans-serif; | |
} | |
table { | |
margin: 1em 0; | |
border-collapse: collapse; | |
} | |
th, | |
td { | |
padding: 0.5em; | |
border: solid 1px #ccc; | |
} | |
th, | |
th[scope='colgroup'], | |
th[scope='col'] { | |
text-align: center; | |
vertical-align: top; | |
} | |
th[scope='rowgroup'], | |
th[scope='row'] { | |
text-align: left; | |
} | |
th[scope='colgroup'], | |
th[scope='rowgroup'] { | |
background: orange; | |
} | |
th:not([scope]) { | |
background: red; | |
} | |
pre { | |
padding: 0.25em 0.5em; | |
border: solid 0.5px green; | |
font-family: 'SF Mono', 'Inconsolata', 'Consolas', monospace; | |
font-size: 0.65em; | |
line-height: 1.55; | |
} |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
<section id="input"> | |
<h2>Input</h2> | |
<pre>const h = new Hierarchy(null, | |
new Hierarchy('1'), // leaf | |
new Hierarchy('2', | |
new Hierarchy('3', | |
new Hierarchy('4'), // leaf | |
new Hierarchy('5')), // leaf | |
new Hierarchy('6') // leaf | |
), | |
new Hierarchy('7', | |
new Hierarchy('8', | |
new Hierarchy('9') // leaf | |
), | |
new Hierarchy('A') // leaf | |
), | |
new Hierarchy('B') // leaf | |
);</pre> | |
</section> | |
<section id="dynamic"> | |
<h2>Dynamic</h2> | |
<div></div> | |
</section> | |
<section id="static"> | |
<h2>Static</h2> | |
<div> | |
<h3>Row-Oriented</h3> | |
<table> | |
<tr> | |
<th scope="row" colspan="3">1</th> | |
</tr> | |
<tr> | |
<th scope="rowgroup" rowspan="3">2</th> | |
<th scope="rowgroup" rowspan="2">3</th> | |
<th scope="row">4</th> | |
</tr> | |
<tr> | |
<th scope="row">5</th> | |
</tr> | |
<tr> | |
<th scope="row" colspan="2">6</th> | |
</tr> | |
<tr> | |
<th scope="rowgroup" rowspan="2">7</th> | |
<th scope="rowgroup">8</th> | |
<th scope="row">9</th> | |
</tr> | |
<tr> | |
<th scope="row" colspan="2">A</th> | |
</tr> | |
<tr> | |
<th scope="row" colspan="3">B</th> | |
</tr> | |
</table> | |
<h3>Column-Oriented</h3> | |
<table> | |
<thead> | |
<tr> | |
<th scope="col" rowspan="3">1</th> | |
<th scope="colgroup" colspan="3">2</th> | |
<th scope="colgroup" colspan="2">7</th> | |
<th scope="col" rowspan="3">B</th> | |
</tr> | |
<tr> | |
<th scope="colgroup" colspan="2">3</th> | |
<th scope="col" rowspan="2">6</th> | |
<th scope="colgroup">8</th> | |
<th scope="col" rowspan="2">A</th> | |
</tr> | |
<tr> | |
<th scope="col">4</th> | |
<th scope="col">5</th> | |
<th scope="col">9</th> | |
</tr> | |
</thead> | |
</table> | |
</div> | |
</section> |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
/** | |
* Whether something is iterable, not including `string` instances. | |
* | |
* @param {*} item | |
* @param {boolean} [ignoreStrings = true] | |
* @return {boolean} | |
*/ | |
function isIterable(item, ignoreStrings = true) { | |
if (!exists(item)) return false; | |
if ('function' === typeof item[Symbol.iterator]) { | |
return 'string' !== typeof item || !ignoreStrings; | |
} | |
return false; | |
} | |
/** | |
* Whether something is not `undefined` or `null` | |
* | |
* @param {*} item | |
* @return {boolean} | |
*/ | |
function exists(item) { | |
return !('undefined' === typeof item || null === item); | |
} | |
/** | |
* Whether something doesn’t exist or is *not* an empty `string` | |
* | |
* @param {*} item | |
* @return {boolean} | |
*/ | |
function isEmpty(item) { | |
return !exists(item) || '' === item; | |
} | |
/** | |
* Guarantees an interable, even if passed a non-iterable, | |
* except for `undefined` and `null`, which are returned as-is. | |
* | |
* @param {*} oneOrMany | |
* @return {Iterable|Array|null|undefined} | |
*/ | |
function toIterable(oneOrMany) { | |
if (!exists(oneOrMany)) return oneOrMany; | |
if (isIterable(oneOrMany)) return oneOrMany; | |
return [oneOrMany]; | |
} | |
/** | |
* Creates a `Node` instance. | |
* | |
* @param {Node|string|null|undefined} name | |
* @return {Node} | |
*/ | |
function createElement(name) { | |
if (name instanceof Node) return name; | |
if (isEmpty(name)) return document.createDocumentFragment(); | |
return document.createElement(String(name)); | |
} | |
/** | |
* | |
* @param {Iterable|Node|string|Object} param | |
* @param {Node} el | |
* @return {Node} | |
*/ | |
function applyToElement(param, el) { | |
if (isIterable(param)) { | |
for (const item of param) { | |
applyToElement(item, el); | |
} | |
return el; | |
} | |
if (param instanceof Node) { | |
el.appendChild(param); | |
return el; | |
} | |
if ('string' === typeof param) { | |
el.appendChild(document.createTextNode(param)); | |
return el; | |
} | |
if (exists(param) && 'object' === typeof param) { | |
for (const p of [ | |
...Object.getOwnPropertyNames(param), | |
...Object.getOwnPropertySymbols(param) | |
]) { | |
switch (p) { | |
case 'style': | |
case 'dataset': | |
for (let item in param[p]) { | |
if (exists(item)) el[p][item] = param[p][item]; | |
} | |
break; | |
case 'class': | |
case 'className': | |
case 'classList': | |
for (const cls of toIterable(param[p])) { | |
if (exists(cls)) el.classList.add(cls); | |
} | |
break; | |
default: | |
el[p] = param[p]; | |
} | |
} | |
} | |
return el; | |
} | |
/** | |
* | |
* @param {string|Node|undefined|null} name | |
* @param {Iterable} rest | |
* @return {Node} | |
*/ | |
function element(name, ...rest) { | |
const el = createElement(name); | |
for (const param of rest) { | |
applyToElement(param, el); | |
} | |
return el; | |
} | |
const toFragment = (...rest) => element(null, ...rest); | |
const empty = () => toFragment(); | |
const header = (...rest) => element('header', ...rest); | |
const nav = (...rest) => element('nav', ...rest); | |
const footer = (...rest) => element('footer', ...rest); | |
const div = (...rest) => element('div', ...rest); | |
const p = (...rest) => element('p', ...rest); | |
const h1 = (...rest) => element('h1', ...rest); | |
const h2 = (...rest) => element('h2', ...rest); | |
const h3 = (...rest) => element('h3', ...rest); | |
const h4 = (...rest) => element('h4', ...rest); | |
const h5 = (...rest) => element('h5', ...rest); | |
const h6 = (...rest) => element('h6', ...rest); | |
const ul = (...rest) => element('ul', ...rest); | |
const ol = (...rest) => element('ol', ...rest); | |
const li = (...rest) => element('li', ...rest); | |
const dl = (...rest) => element('dl', ...rest); | |
const dt = (...rest) => element('dt', ...rest); | |
const dd = (...rest) => element('dd', ...rest); | |
const table = (...rest) => element('table', ...rest); | |
const thead = (...rest) => element('thead', ...rest); | |
const tfoot = (...rest) => element('tfoot', ...rest); | |
const tbody = (...rest) => element('tbody', ...rest); | |
const tr = (...rest) => element('tr', ...rest); | |
const th = (...rest) => element('th', ...rest); | |
const td = (...rest) => element('td', ...rest); | |
const span = (...rest) => element('span', ...rest); | |
const a = (...rest) => element('a', ...rest); | |
const em = (...rest) => element('em', ...rest); | |
const strong = (...rest) => element('strong', ...rest); | |
const mark = (...rest) => element('mark', ...rest); | |
const input = (...rest) => element('input', { type: 'text' }, ...rest); | |
const button = (...rest) => element('button', ...rest); | |
const text = input; | |
const textarea = (...rest) => element('textarea', ...rest); | |
const checkbox = (...rest) => element('input', { type: 'checkbox' }, ...rest); | |
const radio = (...rest) => element('input', { type: 'radio' }, ...rest); | |
const select = (...rest) => element('select', ...rest); | |
const option = (...rest) => element('option', ...rest); | |
const file = (...rest) => element('input', { type: 'file' }, ...rest); | |
const br = (...rest) => element('br', ...rest); | |
const hr = (...rest) => element('hr', ...rest); | |
/** | |
* Replaces the entire contents of `oldNode` with `newChild`. | |
* It’s generally advisable to use a `DocumentFragment` for the | |
* the replacement. | |
* | |
* @param {Node} oldNode | |
* @param {Node|DocumentFragment|NodeList|Array<Node>} newChild | |
* @returns {Node} - The new parent wrapper | |
*/ | |
function replaceChildren(oldNode, newChild) { | |
if (!oldNode) return; | |
const tmpParent = oldNode.cloneNode(); | |
if (newChild) { | |
if (newChild instanceof Node) { | |
tmpParent.appendChild(newChild); | |
} else { | |
Array.prototype.forEach.call(newChild, child => | |
tmpParent.appendChild(child) | |
); | |
} | |
} | |
oldNode.parentNode.replaceChild(tmpParent, oldNode); | |
return tmpParent; | |
} | |
/*******************/ | |
/** | |
* A tree data structure. Each node as a label and an ordered list of | |
* `Hierarchy` children. | |
*/ | |
class Hierarchy { | |
/** | |
* | |
* @param {Object} data | |
* @param {...Hierarchy} children | |
*/ | |
constructor(data, ...children) { | |
this.data = data; | |
this.children = children || []; | |
} | |
/** | |
* The total number of *leaf nodes* underneath the current node. | |
* This is useful for colspan on vertically-oriented hierarchies. | |
*/ | |
get leaves() { | |
if (this.hasChildren) { | |
return this.children.reduce((prev, curr) => prev + curr.leaves, 0); | |
} | |
return 1; | |
} | |
get hasChildren() { | |
return this.children.length > 0; | |
} | |
/** | |
* The *maximum depth* under the current node. This is useful for rowspan in | |
* vertically-oriented hierarchies. | |
*/ | |
get depth() { | |
const max = (prev, curr) => Math.max(curr.depth, prev); | |
return 1 + (this.hasChildren ? this.children.reduce(max, 0) : 0); | |
} | |
/** | |
* Depth-first traversal | |
* | |
* @param {Function} callback | |
* @return {undefined} | |
*/ | |
traverse(callback) { | |
(function recurse(node, parent) { | |
callback(node, parent); | |
for (const child of node.children) { | |
recurse(child, node); | |
} | |
})(this); | |
} | |
toString(indent = '—') { | |
return this.data && this.data.label | |
? this.data.label | |
: String(this.data) + | |
this.children.reduce( | |
(prev, curr) => prev + '\n' + curr.toString(indent + indent[0]), | |
'' | |
); | |
} | |
} | |
/** | |
* Converts a dictionary-style `Object` into a `Hierarchy`, using the object’s | |
* ennumerable properties. | |
* | |
* @param {*} obj | |
* @return {Hierarchy} | |
* @static | |
*/ | |
Hierarchy.from = function from(obj) { | |
// TODO | |
}; | |
// prettier-ignore | |
const h = new Hierarchy(null, | |
new Hierarchy({ label: '1' }), | |
new Hierarchy({ label: '2' }, | |
new Hierarchy({ label: '3' }, | |
new Hierarchy({ label: '4' }), | |
new Hierarchy({ label: '5' })), | |
new Hierarchy({ label: '6' }) | |
), | |
new Hierarchy({ label: '7' }, | |
new Hierarchy({ label: '8' }, | |
new Hierarchy({ label: '9' }) | |
), | |
new Hierarchy({ label: 'A' }) | |
), | |
new Hierarchy({ label: 'B' }) | |
); | |
/* | |
const h = new Hierarchy(null, | |
new Hierarchy({ label: '1' }), | |
new Hierarchy({ label: '2' }, | |
new Hierarchy({ label: '3' }, | |
new Hierarchy({ label: '4' }), | |
new Hierarchy({ label: '5' })), | |
new Hierarchy({ label: '6' }) | |
), | |
new Hierarchy({ label: '7' }, | |
new Hierarchy({ label: '8' }, | |
new Hierarchy({ label: '9' }) | |
), | |
new Hierarchy({ label: 'A' }) | |
), | |
new Hierarchy({ label: 'B' }, | |
new Hierarchy({ label: 'C' }, | |
new Hierarchy({ label: 'D' }), | |
new Hierarchy({ label: 'E' }, | |
new Hierarchy({ label: 'F' }) | |
) | |
) | |
) | |
); | |
*/ | |
function renderVerticalHierarchy(hierarchy) { | |
// Place holder of temporary hierarchy | |
const next = new Hierarchy(null); | |
if (0 === hierarchy.children.length) return empty(); | |
const rows = hierarchy.children.map(node => { | |
next.children.push(...node.children); | |
const leaves = node.leaves; | |
return th(node.data.label, { | |
scope: node.hasChildren ? 'colgroup' : 'col', | |
colSpan: leaves, | |
rowSpan: node.hasChildren ? 1 : hierarchy.depth | |
}); | |
}); | |
return toFragment(tr(rows), renderVerticalHierarchy(next)); | |
} | |
function renderHorizontalHierarchy(hierarchy) { | |
let accum = []; | |
const rows = []; | |
hierarchy.traverse((node, parent) => { | |
// console.log(node, parent, node.depth); | |
if (null === node.data) return; | |
const prop = { | |
scope: node.hasChildren ? 'rowgroup' : 'row', | |
rowSpan: node.leaves, | |
colSpan: parent.depth - node.depth | |
}; | |
accum.push(th(node.data.label, prop)); | |
if (!node.hasChildren) { | |
rows.push(accum); | |
accum = []; | |
} | |
}); | |
return rows.map(r => tr(r)); | |
} | |
const el = document.querySelector('#dynamic>div'); | |
// h.traverse(node => console.log(node.data.label)); | |
el.appendChild(table(thead(renderHorizontalHierarchy(h)))); | |
el.appendChild(table(thead(renderVerticalHierarchy(h)))); |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment