podtable
Version:
Podtable is a lightweight library to make HTML table fit into smaller device screens
605 lines (511 loc) • 17.6 kB
JavaScript
import { watch, getTable, detachRows } from './utils'
function Podtable(tableEl, config = {}) {
/**
* default config options
* @type Object
*/
let defaultOptions = {
keepCell: [],
priority: [],
method: null,
rowGroup: false
}
/**
* Podtable instance
* @return object
*/
let _this = {}
/**
* config options
*/
let options = Object.assign({}, defaultOptions, config)
/**
* The associated table that podtable will render
* @returns HTMLTableElement
*/
const table = getTable(tableEl)
/**
* This is the squishitude determinant row
* @type HTMLTableRowElement
*/
let targetRow
/**
* A wrapper for the render table
* @returns HTMLElement
*/
let tableContainer
/**
* Perform an health check on the passed table
* @returns void
*/
healthCheck(table)
/**
* Cache container width after health check passed
*/
let oldTableContainerWidth = tableContainer.clientWidth
/**
* This is store for currently hidden cells
* @type Array
*/
let hiddenCells = []
/**
* Constant index of cells generated from target row
* @type Array
*/
let constIndex = []
/**
* @type object
*/
let state = { current: -1 }
/**
* Process the config options passed
* @returns void
*/
processConfig()
/**
* Attach event listeners for control toggle
* @returns void
*/
setToggleCell(table)
/**
* Resize event method
*/
_this.watchResize = false
/**
* Resize event method
*/
_this.nativeResize = false
/**
* observer event method
*/
_this.observer = false
/**
* Renders the table for the first instance
* @returns void
*/
render()
/**
* Starts a mutation observer
* @returns void
*/
watchMutation(table)
/**
* Set rendering target row
* @param {HTMLTableElement} table
*/
function setTargetRow(table) {
targetRow = table.tHead.rows[0]
}
/**
* set the wrapper for podtable
*/
function setWrapper() {
tableContainer = document.createElement('div')
tableContainer.setAttribute('id', 'podtable-container')
table.parentNode.insertBefore(tableContainer, table)
tableContainer.appendChild(table)
}
/**
* Perform health check and if it fail will throw an error
* @param {HTMLTableElement} table
*/
function healthCheck(table) {
if (!(table instanceof HTMLTableElement) || table == null || table.tHead == null) {
throw new Error('Invalid HTMLTableElement')
}
if (table.tHead.rows.length <= 0 || table.tHead.rows[0].cells.length < 0) {
throw new Error('Invalid tHead HTMLTableRowElement')
}
setTargetRow(table)
setWrapper()
}
/**
* Sets the control cells CSS clasess
* @param {String} tableEl
*/
function setToggleCell(table) {
for (let row of table.rows) {
if (row.parentElement.tagName.toUpperCase() == 'TBODY') {
if (!row.hasAttribute('data-ptr-ignore')) {
row.lastElementChild.classList.add('toggle')
}
}
if (row.parentElement.tagName.toUpperCase() == 'THEAD') {
row.lastElementChild.classList.add('main-toggle')
}
}
}
/**
* Set cell hidden priority from the right
* Set indexes of cells to keep
*/
function processConfig() {
let tempConst = []
for (let ci = 0; ci < targetRow.cells.length; ci++) {
tempConst.push(ci)
}
if (Array.isArray(options.priority) && options.priority.length > 0) {
constIndex = Array.from(new Set(options.priority.concat(tempConst.reverse())))
} else {
constIndex = tempConst.reverse()
}
if (!Array.isArray(options.keepCell)) {
throw TypeError('keepCell is not an array')
} else {
options.keepCell.push(0, tempConst.length - 1)
}
}
/**
* Create HTMLTableRowElement & append cell column data
* @param {HTMLCollection} cells
* @returns HTMLTableRowElement
*/
function childRow(cells) {
let tr = document.createElement('tr')
let gridTD = document.createElement('td')
let gridRow = document.createElement('div')
gridTD.colSpan = constIndex.length
gridRow.classList.add('child-grid-row')
tr.classList.add('child')
for (let i = 0; i < cells.length; i++) {
gridRow.append(cells[i])
}
gridTD.append(gridRow)
tr.append(gridTD)
return tr
}
/**
* Create HTMLElement to append to child row
* @param {HTMLTableCellElement} el
* @returns HTMLElement
*/
function gridCol(el) {
let gridCol = document.createElement('div')
gridCol.classList.add('child-grid-col')
let dataColName = document.createElement('div')
let dataColDesc = document.createElement('div')
dataColName.innerHTML = table.tHead.rows[0].cells[el.cellIndex].innerHTML
dataColDesc.innerHTML = el.innerHTML
gridCol.append(dataColName)
gridCol.append(dataColDesc)
return gridCol
}
/**
* Toggle single child row and calculate hidden element for the row
* @param {event} e
*/
function toggle(e) {
if (hiddenCells.length <= 0) { return }
let parent = e.currentTarget.parentElement
if (parent.classList.contains('has-child')) {
parent.classList.remove('has-child')
parent.nextElementSibling.remove()
} else {
parent.classList.add('has-child')
let isHidden = []
for (let i = 0; i < parent.cells.length; i++) {
if (parent.cells[i].classList.contains('hidden')) {
isHidden.push(gridCol(parent.cells[i]))
}
}
parent.parentNode.insertBefore(childRow(isHidden), parent.nextSibling)
}
}
/**
* Handles toggle all child rows event by checking which rows
* has child to close and which rows has no child to open
* @param {event} e
*/
function toggleAll(e) {
if (hiddenCells.length <= 0) { return }
let toggleEls = document.querySelectorAll('.toggle')
let toggler = e.currentTarget
if (toggler.classList.contains('expanded')) {
for (let i = 0; i < toggleEls.length; i++) {
let togsParent = toggleEls[i].parentElement
if (togsParent.classList.contains('has-child')) {
toggleEls[i].click()
}
}
toggler.classList.remove('expanded')
} else {
for (let i = 0; i < toggleEls.length; i++) {
let togsParent = toggleEls[i].parentElement
if (!togsParent.classList.contains('has-child')) {
toggleEls[i].click()
}
}
toggler.classList.add('expanded')
}
}
/**
* Adds click Event listener to rows with css class of
* toggle and main-toggle so as to toggle child rows
* @return void
*/
function addToggleListener() {
let togElements = document.querySelectorAll('.toggle')
for (let i = 0; i < togElements.length; i++) {
togElements[i].addEventListener('click', toggle)
}
let mainToggle = document.querySelector('.main-toggle')
mainToggle.addEventListener('click', toggleAll)
}
/**
* Remove control toggle listener on rows
* @return object
*/
function removeToggleListener() {
let togElements = document.querySelectorAll('.toggle')
for (let i = 0; i < togElements.length; i++) {
togElements[i].removeEventListener('click', toggle)
}
let mainToggle = document.querySelector('.main-toggle')
mainToggle.removeEventListener('click', toggleAll)
}
/**
* Check if there are hidden elements ands determine when to show
* child row toggle button and also clean up unused css class.
*/
function doTogglerScreen() {
if (hiddenCells.length > 0) {
table.classList.add('show-toggle')
} else {
document.querySelectorAll('.has-child').forEach(el => {
el.classList.remove('has-child')
})
table.classList.remove('show-toggle')
table.tHead.rows[0].cells[table.tHead.rows[0].cells.length - 1].classList.remove('expanded')
}
}
/**
* Check for open child rows to enable reactivity as window resizes
* then apply changes, item are remove and added every time window resize
* and its like this so as to get an updated data from the cells
* child row are redrawn on each control toggle.
*/
function childRowListener() {
let childRows = document.querySelectorAll('tr.child')
if (childRows.length > 0) {
let parentRows = []
for (let row of childRows) {
parentRows.push(row.previousElementSibling)
}
// Iterate from parents elements down to child elements
for (let p = 0; p < parentRows.length; p++) {
let isHidden = []
for (let cell of parentRows[p].cells) {
if (cell.classList.contains('hidden')) {
isHidden.push(gridCol(cell))
}
}
// we will remove the existing child row and put another one with new data
// we also check if the hidden cells length > 0 before inserting a new child row
// so as to avoid empty child rows and orphaned child rows
parentRows[p].nextElementSibling.remove()
if (hiddenCells.length > 0) {
parentRows[p].after(childRow(isHidden))
}
doTogglerScreen()
}
}
}
/**
* Hide cells that falls into maximum squishitude
* Dispatch event for the current hidden cells index
* @param {Number} index
*/
function hideMain(index, pt = table) {
hiddenCells.push(index)
for (let row of pt.rows) {
if (!row.classList.contains('child') && !row.hasAttribute('data-ptr-ignore')) {
row.cells[index].classList.add('hidden')
}
}
eventDispatch(index)
}
/**
* Here we remove the hidden class and flush the hidden cells
* array so as to restart procedure for the current viewport.
*/
function flush() {
for (let i = 0; i < hiddenCells.length; i++) {
for (let row of table.rows) {
if (!row.classList.contains('child') && !row.hasAttribute('data-ptr-ignore')) {
row.cells[hiddenCells[i]].classList.remove('hidden')
}
}
}
hiddenCells = []
}
/**
* Recalculate Cells thats needs to be hidden after flushing
*/
function recalc() {
flush()
for (let i = 0; i < constIndex.length; i++) {
if (targetRow.clientWidth > tableContainer.clientWidth) {
if (!hiddenCells.includes(constIndex[i])) {
if (!options.keepCell.includes(constIndex[i])) {
hideMain(constIndex[i])
childRowListener()
}
}
}
}
doTogglerScreen()
}
/**
* This method recalculate which cells to hide or show and dispatch
* event with negative index to indicate there are no hiddenCells
*/
function resize() {
recalc()
if (hiddenCells.length <= 0) {
eventDispatch(-1)
childRowListener()
}
}
/**
* On mounted calculate cells which can fit into the current
* maximum squishitude: apply visibility, attach listeners.
*/
function mount() {
hiddenCells = []
let ilength = constIndex.length
for (let i = 0; i < ilength; i++) {
if (targetRow.clientWidth > tableContainer.clientWidth) {
if (!hiddenCells.includes(constIndex[i])) {
if (!options.keepCell.includes(constIndex[i])) {
hideMain(constIndex[i])
}
}
}
}
doTogglerScreen()
}
/**
* This is the resize counterpart for window resize event
* or element watcher event
* @see resize
*/
function observeResize() {
recalc()
if (hiddenCells.length <= 0) {
eventDispatch(-1)
childRowListener()
}
}
/**
* Handles resize listener
* @returns boolean|ResizeObserver
*/
function observed() {
try {
_this.nativeResize = new ResizeObserver((entries) => {
if (entries[0].target.clientWidth !== oldTableContainerWidth) {
observeResize()
}
oldTableContainerWidth = entries[0].target.clientWidth
})
_this.nativeResize.observe(tableContainer)
return true
} catch (err) {
return false
}
}
/**
* Starts an observer at podtable instance and
* attach necessary listeners
*/
function render() {
mount()
addToggleListener()
if (!observed()) {
try {
_this.watchResize = watch(tableContainer, resize)
_this.watchResize.start()
} catch (err) {
window.addEventListener('resize', resize)
}
}
}
/**
* Revert all events made to the DOM
*/
function destroy() {
window.removeEventListener('resize', resize)
flush()
detachRows(table, tableContainer, _this)
removeToggleListener()
}
/**
* Revert any alteration to the table in the DOM
* and detach events
* @returns void
*/
state.destroy = () => destroy()
/**
* Handles childList mutations by re-attaching attributes,
* events and dispatching events
* @param {HTMLTableElement} table
*/
function watchMutation(table) {
function resetRow(node) {
if (!node.hasAttribute('data-ptr-ignore')) {
node.lastElementChild.classList.add('toggle')
node.lastElementChild.addEventListener('click', (e) => toggle(e))
}
}
const callback = (mutations) => {
for (const mutation of mutations) {
if (mutation.type == 'childList' && mutation.addedNodes.length == 1) {
if (mutation.addedNodes[0].tagName.toUpperCase() == 'TBODY') {
for (let node of mutation.addedNodes[0].children) {
resetRow(node)
shouldPing()
}
}
if (mutation.addedNodes[0].tagName.toUpperCase() == 'TR' && !mutation.addedNodes[0].classList.contains('child')) {
resetRow(mutation.addedNodes[0])
shouldPing()
}
} else if (mutation.type == 'childList' && mutation.removedNodes.length == 1) {
if (mutation.removedNodes[0].tagName.toUpperCase() == 'TR' &&
!mutation.removedNodes[0].classList.contains('child') &&
mutation.removedNodes[0].classList.contains('has-child')) {
mutation.nextSibling.remove()
}
}
}
setTargetRow(table)
flush()
mount()
}
let observable = options.rowGroup ? table : table.tBodies[0]
_this.observer = new MutationObserver(callback)
_this.observer.observe(observable, { childList: true })
}
/**
* Dispatch event for the current hidden cell index
* @param {Number} index
*/
function eventDispatch(index) {
state.current = index
if (options.method) { shouldPing() }
}
/**
* Dispatchs event
*/
function shouldPing() {
if (options.method) {
try {
options.method(state)
} catch (err) {
console.error(err)
}
}
}
return state
}
export default Podtable