simple-datatables
Version:
A lightweight, dependency-free JavaScript HTML table plugin.
1,147 lines (965 loc) • 39.8 kB
text/typescript
import {
cellToText,
classNamesToSelector,
containsClass,
createElement,
debounce,
isObject,
joinWithSpaces,
visibleToColumnIndex
} from "./helpers"
import {
cellType,
DataTableConfiguration,
DataTableOptions,
dataRowType,
filterStateType,
headerCellType,
inputCellType,
inputRowType,
elementNodeType,
renderOptions,
rowType,
TableDataType
} from "./types"
import {DiffDOM, nodeToObj} from "diff-dom"
import {dataToVirtualDOM, headingsToVirtualHeaderRowDOM} from "./virtual_dom"
import {readTableData, readDataCell, readHeaderCell} from "./read_data"
import {Rows} from "./rows"
import {Columns} from "./columns"
import {defaultConfig} from "./config"
import {createVirtualPagerDOM} from "./virtual_pager_dom"
export class DataTable {
columns: Columns
containerDOM: HTMLDivElement
_currentPage: number
data: TableDataType
_dd: DiffDOM
dom: HTMLTableElement
_events: { [key: string]: ((...args) => void)[]}
hasHeadings: boolean
hasRows: boolean
headerDOM: HTMLDivElement
_initialHTML: string
initialized: boolean
_label: HTMLElement
lastPage: number
_listeners: { [key: string]: () => void}
onFirstPage: boolean
onLastPage: boolean
options: DataTableConfiguration
_pagerDOMs: HTMLElement[]
_virtualPagerDOM: elementNodeType
pages: rowType[][]
_rect: {width: number, height: number}
rows: Rows
_searchData: number[]
_searchQueries: {source: string, terms: string[], columns: (number[] | undefined)}[]
_tableAttributes: {[key: string]: string}
_tableFooters: elementNodeType[]
_tableCaptions: elementNodeType[]
totalPages: number
_virtualDOM: elementNodeType
_virtualHeaderDOM: elementNodeType
wrapperDOM: HTMLElement
constructor(table: HTMLTableElement | string, options: DataTableOptions = {}) {
const dom = typeof table === "string" ?
document.querySelector(table) :
table
if (dom instanceof HTMLTableElement) {
this.dom = dom
} else {
this.dom = document.createElement("table")
dom.appendChild(this.dom)
}
const diffDomOptions = {
...defaultConfig.diffDomOptions,
...options.diffDomOptions
}
const labels = {
...defaultConfig.labels,
...options.labels
}
const classes = {
...defaultConfig.classes,
...options.classes
}
// user options
this.options = {
...defaultConfig,
...options,
diffDomOptions,
labels,
classes
}
this._initialHTML = this.options.destroyable ? dom.outerHTML : "" // preserve in case of later destruction
if (this.options.tabIndex) {
this.dom.tabIndex = this.options.tabIndex
} else if (this.options.rowNavigation && this.dom.tabIndex === -1) {
this.dom.tabIndex = 0
}
this._listeners = {
onResize: () => this._onResize()
}
this._dd = new DiffDOM(this.options.diffDomOptions || {})
this.initialized = false
this._events = {}
this._currentPage = 0
this.onFirstPage = true
this.hasHeadings = false
this.hasRows = false
this._searchQueries = []
this.init()
}
/**
* Initialize the instance
*/
init() {
if (this.initialized || containsClass(this.dom, this.options.classes.table)) {
return false
}
this._virtualDOM = nodeToObj(this.dom, this.options.diffDomOptions || {})
this._tableAttributes = {...this._virtualDOM.attributes}
this._tableFooters = this._virtualDOM.childNodes?.filter(node => node.nodeName === "TFOOT") ?? []
this._tableCaptions = this._virtualDOM.childNodes?.filter(node => node.nodeName === "CAPTION") ?? []
if (this.options.caption !== undefined) {
this._tableCaptions.push({
nodeName: "CAPTION",
childNodes: [
{
nodeName: "#text",
data: this.options.caption
}
]
})
}
this.rows = new Rows(this)
this.columns = new Columns(this)
this.data = readTableData(this.options.data, this.dom, this.columns.settings, this.options.type, this.options.format)
this._render()
setTimeout(() => {
this.emit("datatable.init")
this.initialized = true
}, 10)
}
/**
* Render the instance
*/
_render() {
// Build
this.wrapperDOM = createElement("div", {
class: `${this.options.classes.wrapper} ${this.options.classes.loading}`
})
this.wrapperDOM.innerHTML = this.options.template(this.options, this.dom)
const selectorClassSelector = classNamesToSelector(this.options.classes.selector)
const selector = this.wrapperDOM.querySelector(`select${selectorClassSelector}`)
// Per Page Select
if (selector && this.options.paging && this.options.perPageSelect) {
// Create the options
this.options.perPageSelect.forEach((choice: number | [string, number]) => {
const [lab, val] = Array.isArray(choice) ? [choice[0], choice[1]] : [String(choice), choice]
const selected = val === this.options.perPage
const option = new Option(lab, String(val), selected, selected)
selector.appendChild(option)
})
} else if (selector) {
selector.parentElement.removeChild(selector)
}
const containerSelector = classNamesToSelector(this.options.classes.container)
this.containerDOM = this.wrapperDOM.querySelector(containerSelector)
this._pagerDOMs = []
const paginationSelector = classNamesToSelector(this.options.classes.pagination)
Array.from(this.wrapperDOM.querySelectorAll(paginationSelector)).forEach(el => {
if (!(el instanceof HTMLElement)) {
return
}
// We remove the inner part of the pager containers to ensure they are all the same.
el.innerHTML = `<ul class="${this.options.classes.paginationList}"></ul>`
this._pagerDOMs.push(el.firstElementChild as HTMLElement)
})
this._virtualPagerDOM = {
nodeName: "UL",
attributes: {
class: this.options.classes.paginationList
}
}
const infoSelector = classNamesToSelector(this.options.classes.info)
this._label = this.wrapperDOM.querySelector(infoSelector)
// Insert in to DOM tree
this.dom.parentElement.replaceChild(this.wrapperDOM, this.dom)
this.containerDOM.appendChild(this.dom)
// Store the table dimensions
this._rect = this.dom.getBoundingClientRect()
// Fix height
this._fixHeight()
// Class names
if (!this.options.header) {
this.wrapperDOM.classList.add("no-header")
}
if (!this.options.footer) {
this.wrapperDOM.classList.add("no-footer")
}
if (this.options.sortable) {
this.wrapperDOM.classList.add("sortable")
}
if (this.options.searchable) {
this.wrapperDOM.classList.add("searchable")
}
if (this.options.fixedHeight) {
this.wrapperDOM.classList.add("fixed-height")
}
if (this.options.fixedColumns) {
this.wrapperDOM.classList.add("fixed-columns")
}
this._bindEvents()
if (this.columns._state.sort) {
this.columns.sort(this.columns._state.sort.column, this.columns._state.sort.dir, true)
}
this.update(true)
}
_renderTable(renderOptions: renderOptions = {}) {
let rows: rowType[]
const isPaged = (this.options.paging || this._searchQueries.length || this.columns._state.filters.length) && this._currentPage && this.pages.length && !renderOptions.noPaging
if (isPaged) {
rows = this.pages[this._currentPage - 1]
} else {
rows = this.data.data.map((row, index) => ({
row,
index
}))
}
let newVirtualDOM = dataToVirtualDOM(
this._tableAttributes,
this.data.headings,
rows,
this.columns.settings,
this.columns._state,
this.rows.cursor,
this.options,
renderOptions,
this._tableFooters,
this._tableCaptions
)
if (this.options.tableRender) {
const renderedTableVirtualDOM : (elementNodeType | void) = this.options.tableRender(this.data, newVirtualDOM, "main")
if (renderedTableVirtualDOM) {
newVirtualDOM = renderedTableVirtualDOM
}
}
const diff = this._dd.diff(this._virtualDOM, newVirtualDOM)
this._dd.apply(this.dom, diff)
this._virtualDOM = newVirtualDOM
}
/**
* Render the page
* @return {Void}
*/
_renderPage(lastRowCursor=false) {
if (this.hasRows && this.totalPages) {
if (this._currentPage > this.totalPages) {
this._currentPage = 1
}
// Use a fragment to limit touching the DOM
this._renderTable()
this.onFirstPage = this._currentPage === 1
this.onLastPage = this._currentPage === this.lastPage
} else {
this.setMessage(this.options.labels.noRows)
}
// Update the info
let current = 0
let f = 0
let t = 0
let items
if (this.totalPages) {
current = this._currentPage - 1
f = current * this.options.perPage
t = f + this.pages[current].length
f = f + 1
items = this._searchQueries.length ? this._searchData.length : this.data.data.length
}
if (this._label && this.options.labels.info.length) {
// CUSTOM LABELS
const string = this.options.labels.info
.replace("{start}", String(f))
.replace("{end}", String(t))
.replace("{page}", String(this._currentPage))
.replace("{pages}", String(this.totalPages))
.replace("{rows}", String(items))
this._label.innerHTML = items ? string : ""
}
if (this._currentPage == 1) {
this._fixHeight()
}
if (this.options.rowNavigation && this._currentPage) {
if (!this.rows.cursor || !this.pages[this._currentPage-1].find(
row => row.index === this.rows.cursor)
) {
const rows = this.pages[this._currentPage-1]
if (rows.length) {
if (lastRowCursor) {
this.rows.setCursor(rows[rows.length-1].index)
} else {
this.rows.setCursor(rows[0].index)
}
}
}
}
}
/** Render the pager(s)
*
*/
_renderPagers() {
if (!this.options.paging) {
return
}
let newPagerVirtualDOM = createVirtualPagerDOM(this.onFirstPage, this.onLastPage, this._currentPage, this.totalPages, this.options)
if (this.options.pagerRender) {
const renderedPagerVirtualDOM : (elementNodeType | void) = this.options.pagerRender([this.onFirstPage, this.onLastPage, this._currentPage, this.totalPages], newPagerVirtualDOM)
if (renderedPagerVirtualDOM) {
newPagerVirtualDOM = renderedPagerVirtualDOM
}
}
const diffs = this._dd.diff(this._virtualPagerDOM, newPagerVirtualDOM)
// We may have more than one pager
this._pagerDOMs.forEach((pagerDOM: HTMLElement) => {
this._dd.apply(pagerDOM, diffs)
})
this._virtualPagerDOM = newPagerVirtualDOM
}
// Render header that is not in the same table element as the remainder
// of the table. Used for tables with scrollY.
_renderSeparateHeader() {
const container = this.dom.parentElement
if (!this.headerDOM) {
this.headerDOM = document.createElement("div")
this._virtualHeaderDOM = {
nodeName: "DIV"
}
}
container.parentElement.insertBefore(this.headerDOM, container)
let tableVirtualDOM : elementNodeType = {
nodeName: "TABLE",
attributes: this._tableAttributes,
childNodes: [
{
nodeName: "THEAD",
childNodes: [
headingsToVirtualHeaderRowDOM(
this.data.headings, this.columns.settings, this.columns._state, this.options, {unhideHeader: true})
]
}
]
}
tableVirtualDOM.attributes.class = joinWithSpaces(tableVirtualDOM.attributes.class, this.options.classes.table)
if (this.options.tableRender) {
const renderedTableVirtualDOM : (elementNodeType | void) = this.options.tableRender(this.data, tableVirtualDOM, "header")
if (renderedTableVirtualDOM) {
tableVirtualDOM = renderedTableVirtualDOM
}
}
const newVirtualHeaderDOM = {
nodeName: "DIV",
attributes: {
class: this.options.classes.headercontainer
},
childNodes: [tableVirtualDOM]
}
const diff = this._dd.diff(this._virtualHeaderDOM, newVirtualHeaderDOM)
this._dd.apply(this.headerDOM, diff)
this._virtualHeaderDOM = newVirtualHeaderDOM
// Compensate for scrollbars
const paddingRight = this.headerDOM.firstElementChild.clientWidth - this.dom.clientWidth
if (paddingRight) {
const paddedVirtualHeaderDOM = structuredClone(this._virtualHeaderDOM)
paddedVirtualHeaderDOM.attributes.style = `padding-right: ${paddingRight}px;`
const diff = this._dd.diff(this._virtualHeaderDOM, paddedVirtualHeaderDOM)
this._dd.apply(this.headerDOM, diff)
this._virtualHeaderDOM = paddedVirtualHeaderDOM
}
if (container.scrollHeight > container.clientHeight) {
// scrollbars on one page means scrollbars on all pages.
container.style.overflowY = "scroll"
}
}
/**
* Bind event listeners
* @return {[type]} [description]
*/
_bindEvents() {
// Per page selector
if (this.options.perPageSelect) {
const selectorClassSelector = classNamesToSelector(this.options.classes.selector)
const selector = this.wrapperDOM.querySelector(selectorClassSelector)
if (selector && selector instanceof HTMLSelectElement) {
// Change per page
selector.addEventListener("change", () => {
this.emit("datatable.perpage:before", this.options.perPage)
this.options.perPage = parseInt(selector.value, 10)
this.update()
this._fixHeight()
this.emit("datatable.perpage", this.options.perPage)
}, false)
}
}
// Search input
if (this.options.searchable) {
this.wrapperDOM.addEventListener("input", (event: InputEvent) => {
const inputSelector = classNamesToSelector(this.options.classes.input)
const target = event.target
if (!(target instanceof HTMLInputElement) || !target.matches(inputSelector)) {
return
}
event.preventDefault()
const searches: { terms: string[], columns: (number[] | undefined) }[] = []
const searchFields: HTMLInputElement[] = Array.from(this.wrapperDOM.querySelectorAll(inputSelector))
searchFields.filter(
el => el.value.length
).forEach(
el => {
const andSearch = el.dataset.and || this.options.searchAnd
const querySeparator = el.dataset.querySeparator || this.options.searchQuerySeparator
const terms = querySeparator ? el.value.split(this.options.searchQuerySeparator) : [el.value]
if (andSearch) {
terms.forEach(term => {
if (el.dataset.columns) {
searches.push({
terms: [term],
columns: (JSON.parse(el.dataset.columns) as number[])
})
} else {
searches.push({terms: [term],
columns: undefined})
}
})
} else {
if (el.dataset.columns) {
searches.push({
terms,
columns: (JSON.parse(el.dataset.columns) as number[])
})
} else {
searches.push({terms,
columns: undefined})
}
}
}
)
if (searches.length === 1 && searches[0].terms.length === 1) {
const search = searches[0]
this.search(search.terms[0], search.columns)
} else {
this.multiSearch(searches)
}
})
}
// Pager(s) / sorting
this.wrapperDOM.addEventListener("click", (event: Event) => {
const target = event.target as Element
const hyperlink = target.closest("a, button")
if (!hyperlink) {
return
}
if (hyperlink.hasAttribute("data-page")) {
this.page(parseInt(hyperlink.getAttribute("data-page"), 10))
event.preventDefault()
} else if (containsClass(hyperlink, this.options.classes.sorter)) {
const visibleIndex = Array.from(hyperlink.parentElement.parentElement.children).indexOf(hyperlink.parentElement)
const columnIndex = visibleToColumnIndex(visibleIndex, this.columns.settings)
this.columns.sort(columnIndex)
event.preventDefault()
} else if (containsClass(hyperlink, this.options.classes.filter)) {
const visibleIndex = Array.from(hyperlink.parentElement.parentElement.children).indexOf(hyperlink.parentElement)
const columnIndex = visibleToColumnIndex(visibleIndex, this.columns.settings)
this.columns.filter(columnIndex)
event.preventDefault()
}
}, false)
if (this.options.rowNavigation) {
this.dom.addEventListener("keydown", (event: KeyboardEvent) => {
if (event.key === "ArrowUp") {
event.preventDefault()
event.stopPropagation()
let lastRow: rowType
this.pages[this._currentPage-1].find((row: rowType) => {
if (row.index===this.rows.cursor) {
return true
}
lastRow = row
return false
})
if (lastRow) {
this.rows.setCursor(lastRow.index)
} else if (!this.onFirstPage) {
this.page(this._currentPage-1, true)
}
} else if (event.key === "ArrowDown") {
event.preventDefault()
event.stopPropagation()
let foundRow: boolean
const nextRow = this.pages[this._currentPage-1].find((row: rowType) => {
if (foundRow) {
return true
}
if (row.index===this.rows.cursor) {
foundRow = true
}
return false
})
if (nextRow) {
this.rows.setCursor(nextRow.index)
} else if (!this.onLastPage) {
this.page(this._currentPage+1)
}
} else if (this.options.rowSelectionKeys.includes(event.key)) {
this.emit("datatable.selectrow", this.rows.cursor, event, true)
}
})
}
this.dom.addEventListener("mousedown", (event: Event) => {
const target = event.target
if (!(target instanceof Element)) {
return
}
const row = Array.from(this.dom.querySelectorAll("tbody > tr")).find(row => row.contains(target))
if (row && row instanceof HTMLElement) {
this.emit("datatable.selectrow", parseInt(row.dataset.index, 10), event, this.dom.matches(":focus"))
}
})
window.addEventListener("resize", this._listeners.onResize)
}
/**
* execute on resize and debounce to avoid multiple calls
*/
_onResize = debounce(() => {
this._rect = this.containerDOM.getBoundingClientRect()
if (!this._rect.width) {
return
}
this.update(true)
}, 250)
/**
* Destroy the instance
* @return {void}
*/
destroy() {
if (!this.options.destroyable) {
return
}
if (this.wrapperDOM) {
const parentElement = this.wrapperDOM.parentElement
if (parentElement) {
// Restore the initial HTML
const oldDOM = createElement("div")
oldDOM.innerHTML = this._initialHTML
const oldTable = oldDOM.firstElementChild as HTMLTableElement
parentElement.replaceChild(oldTable, this.wrapperDOM)
this.dom = oldTable
} else {
// Remove the className
this.options.classes.table?.split(" ").forEach(className => this.wrapperDOM.classList.remove(className))
}
}
window.removeEventListener("resize", this._listeners.onResize)
this.initialized = false
}
/**
* Update the instance
* @return {Void}
*/
update(measureWidths = false) {
this.emit("datatable.update:before")
if (measureWidths) {
this.columns._measureWidths()
this.hasRows = Boolean(this.data.data.length)
this.hasHeadings = Boolean(this.data.headings.length)
}
this.options.classes.empty?.split(" ").forEach(className => this.wrapperDOM.classList.remove(className))
this._paginate()
this._renderPage()
this._renderPagers()
if (this.options.scrollY.length) {
this._renderSeparateHeader()
}
this.emit("datatable.update")
}
_paginate() {
let rows: rowType[] = this.data.data.map((row, index) => ({
row,
index
}))
if (this._searchQueries.length) {
rows = []
this._searchData.forEach((index: number) => rows.push({index,
row: this.data.data[index]}))
}
if (this.columns._state.filters.length) {
this.columns._state.filters.forEach(
(filterState: (filterStateType | undefined), column: number) => {
if (!filterState) {
return
}
rows = rows.filter(
(row: {index: number, row: dataRowType}) => {
const cell = row.row.cells[column]
return typeof filterState === "function" ? filterState(cell.data) : cellToText(cell) === filterState
}
)
}
)
}
if (this.options.paging && this.options.perPage > 0) {
// Check for hidden columns
this.pages = rows
.map((_row, i: number) => i % this.options.perPage === 0 ? rows.slice(i, i + this.options.perPage) : null)
.filter((page: {row: dataRowType, index: number}[]) => page)
} else {
this.pages = [rows]
}
this.totalPages = this.lastPage = this.pages.length
if (!this._currentPage) {
this._currentPage = 1
}
return this.totalPages
}
/**
* Fix the container height
*/
_fixHeight() {
if (this.options.fixedHeight) {
this.containerDOM.style.height = null
this._rect = this.containerDOM.getBoundingClientRect()
this.containerDOM.style.height = `${this._rect.height}px`
}
}
/**
* Perform a simple search of the data set
*/
search(term: string, columns: (number[] | undefined ) = undefined, source: string = "search") {
this.emit("datatable.search:before", term, this._searchData)
if (!term.length) {
this._currentPage = 1
this._searchQueries = []
this._searchData = []
this.update()
this.emit("datatable.search", "", [])
this.wrapperDOM.classList.remove("search-results")
return false
}
this.multiSearch([
{terms: [term],
columns: columns ? columns : undefined}
], source)
this.emit("datatable.search", term, this._searchData)
}
/**
* Perform a search of the data set searching for up to multiple strings in various columns
*/
multiSearch(rawQueries: { terms: string[], columns: (number[] | undefined) }[], source: string = "search") {
if (!this.hasRows) return false
this._currentPage = 1
this._searchData = []
// Remove empty queries
let queries = rawQueries.map(query => ({
columns: query.columns,
terms: query.terms.map(term => term.trim()).filter(term => term),
source
})).filter(query => query.terms.length)
this.emit("datatable.multisearch:before", queries, this._searchData)
if (source.length) {
// Add any existing queries from different source
queries = queries.concat(this._searchQueries.filter(query => query.source !== source))
}
this._searchQueries = queries
if (!queries.length) {
this.update()
this.emit("datatable.multisearch", queries, this._searchData)
this.wrapperDOM.classList.remove("search-results")
return false
}
const queryWords = queries.map(query => this.columns.settings.map(
(column, index) => {
if (column.hidden || !column.searchable || (query.columns && !query.columns.includes(index))) {
return false
}
let columnQueries = query.terms
const sensitivity = column.sensitivity || this.options.sensitivity
if (["base", "accent"].includes(sensitivity)) {
columnQueries = columnQueries.map(query => query.toLowerCase())
}
if (["base", "case"].includes(sensitivity)) {
columnQueries = columnQueries.map(query => query.normalize("NFD").replace(/\p{Diacritic}/gu, ""))
}
const ignorePunctuation = column.ignorePunctuation ?? this.options.ignorePunctuation
if (ignorePunctuation) {
columnQueries = columnQueries.map(query => query.replace(/[.,/#!$%^&*;:{}=-_`~()]/g, ""))
}
return columnQueries
}
))
this.data.data.forEach((row: dataRowType, idx: number) => {
const searchRow = row.cells.map((cell, i) => {
const column = this.columns.settings[i]
const customSearchMethod = column.searchMethod || this.options.searchMethod
if (customSearchMethod) {
return cell
}
let content = cellToText(cell).trim()
if (content.length) {
const sensitivity = column.sensitivity || this.options.sensitivity
if (["base", "accent"].includes(sensitivity)) {
content = content.toLowerCase()
}
if (["base", "case"].includes(sensitivity)) {
content = content.normalize("NFD").replace(/\p{Diacritic}/gu, "")
}
const ignorePunctuation = column.ignorePunctuation ?? this.options.ignorePunctuation
if (ignorePunctuation) {
content = content.replace(/[.,/#!$%^&*;:{}=-_`~()]/g, "")
}
}
const searchItemSeparator = column.searchItemSeparator || this.options.searchItemSeparator
return searchItemSeparator ? content.split(searchItemSeparator) : [content]
})
if (
queryWords.every(
(queryColumn, queryIndex) => queryColumn.find(
(queryColumnWord, index) => {
if (!queryColumnWord) {
return false
}
const column = this.columns.settings[index]
const customSearchMethod = column.searchMethod || this.options.searchMethod
if (customSearchMethod) {
return customSearchMethod(queryColumnWord, (searchRow[index] as cellType), row, index, queries[queryIndex].source)
}
return queryColumnWord.find(queryWord => (searchRow[index] as string[]).find(searchItem => searchItem.includes(queryWord)))
}
)
)
) {
this._searchData.push(idx)
}
})
this.wrapperDOM.classList.add("search-results")
if (this._searchData.length) {
this.update()
} else {
this.wrapperDOM.classList.remove("search-results")
this.setMessage(this.options.labels.noResults)
}
this.emit("datatable.multisearch", queries, this._searchData)
}
/**
* Change page
*/
page(page: number, lastRowCursor = false) {
this.emit("datatable.page:before", page)
// We don't want to load the current page again.
if (page === this._currentPage) {
return false
}
if (!isNaN(page)) {
this._currentPage = page
}
if (page > this.pages.length || page < 0) {
return false
}
this._renderPage(lastRowCursor)
this._renderPagers()
this.emit("datatable.page", page)
}
/**
* Add new row data
*/
insert(data: (
{headings?: string[], data?: (inputRowType | inputCellType[])[]} | { [key: string]: inputCellType}[])) {
let rows: dataRowType[] = []
if (Array.isArray(data)) {
const headings = this.data.headings.map((heading: headerCellType) => heading.data ? String(heading.data) : heading.text)
data.forEach((row, rIndex) => {
const r: cellType[] = []
Object.entries(row).forEach(([heading, cell]) => {
const index = headings.indexOf(heading)
if (index > -1) {
r[index] = readDataCell(cell as inputCellType, this.columns.settings[index])
} else if (!this.hasHeadings && !this.hasRows && rIndex === 0) {
r[headings.length] = readDataCell(cell as inputCellType, this.columns.settings[headings.length])
headings.push(heading)
this.data.headings.push(readHeaderCell(heading))
}
})
rows.push({
cells: r
})
})
} else if (isObject(data)) {
if (data.headings && !this.hasHeadings && !this.hasRows) {
this.data = readTableData(data, undefined, this.columns.settings, this.options.type, this.options.format)
} else if (data.data && Array.isArray(data.data)) {
rows = data.data.map(row => {
let attributes: { [key: string]: string }
let cells: inputCellType[]
if (Array.isArray(row)) {
attributes = {}
cells = row
} else {
attributes = row.attributes
cells = row.cells
}
return {
attributes,
cells: cells.map((cell, index) => readDataCell(cell as inputCellType, this.columns.settings[index]))
} as dataRowType
})
}
}
if (rows.length) {
rows.forEach((row: dataRowType) => this.data.data.push(row))
}
this.hasHeadings = Boolean(this.data.headings.length)
if (this.columns._state.sort) {
this.columns.sort(this.columns._state.sort.column, this.columns._state.sort.dir, true)
}
this.update(true)
}
/**
* Refresh the instance
*/
refresh() {
this.emit("datatable.refresh:before")
if (this.options.searchable) {
const inputSelector = classNamesToSelector(this.options.classes.input)
const inputs: HTMLInputElement[] = Array.from(this.wrapperDOM.querySelectorAll(inputSelector))
inputs.forEach(el => (el.value = ""))
this._searchQueries = []
}
this._currentPage = 1
this.onFirstPage = true
this.update(true)
this.emit("datatable.refresh")
}
/**
* Print the table
*/
print() {
const tableDOM = createElement("table")
const tableVirtualDOM = {nodeName: "TABLE"}
let newTableVirtualDOM = dataToVirtualDOM(
this._tableAttributes,
this.data.headings,
this.data.data.map((row, index) => ({
row,
index
})),
this.columns.settings,
this.columns._state,
false, // No row cursor
this.options,
{
noColumnWidths: true,
unhideHeader: true
},
this._tableFooters,
this._tableCaptions
)
if (this.options.tableRender) {
const renderedTableVirtualDOM : (elementNodeType | void) = this.options.tableRender(this.data, newTableVirtualDOM, "print")
if (renderedTableVirtualDOM) {
newTableVirtualDOM = renderedTableVirtualDOM
}
}
const diff = this._dd.diff(tableVirtualDOM, newTableVirtualDOM)
this._dd.apply(tableDOM, diff)
// Open new window
const w = window.open()
// Append the table to the body
w.document.body.appendChild(tableDOM)
// Print
w.print()
}
/**
* Show a message in the table
*/
setMessage(message: string) {
const activeHeadings = this.data.headings.filter((heading: headerCellType, index: number) => !this.columns.settings[index]?.hidden)
const colspan = activeHeadings.length || 1
this.options.classes.empty?.split(" ").forEach(className => this.wrapperDOM.classList.add(className))
if (this._label) {
this._label.innerHTML = ""
}
this.totalPages = 0
this._renderPagers()
let newVirtualDOM : elementNodeType = {
nodeName: "TABLE",
attributes: this._tableAttributes,
childNodes: [
{
nodeName: "THEAD",
childNodes: [
headingsToVirtualHeaderRowDOM(
this.data.headings, this.columns.settings, this.columns._state, this.options, {})
]
},
{
nodeName: "TBODY",
childNodes: [
{
nodeName: "TR",
childNodes: [
{
nodeName: "TD",
attributes: {
class: this.options.classes.empty,
colspan: String(colspan)
},
childNodes: [
{
nodeName: "#text",
data: message
}
]
}
]
}
]
}
]
}
this._tableFooters.forEach(footer => newVirtualDOM.childNodes.push(footer))
this._tableCaptions.forEach(caption => newVirtualDOM.childNodes.push(caption))
newVirtualDOM.attributes.class = joinWithSpaces(newVirtualDOM.attributes.class, this.options.classes.table)
if (this.options.tableRender) {
const renderedTableVirtualDOM : (elementNodeType | void) = this.options.tableRender(this.data, newVirtualDOM, "message")
if (renderedTableVirtualDOM) {
newVirtualDOM = renderedTableVirtualDOM
}
}
const diff = this._dd.diff(this._virtualDOM, newVirtualDOM)
this._dd.apply(this.dom, diff)
this._virtualDOM = newVirtualDOM
}
/**
* Add custom event listener
*/
on(event: string, callback: (...args: any[]) => void) {
this._events[event] = this._events[event] || []
this._events[event].push(callback)
}
/**
* Remove custom event listener
*/
off(event: string, callback: (...args: any[]) => void) {
if (event in this._events === false) return
this._events[event].splice(this._events[event].indexOf(callback), 1)
}
/**
* Fire custom event
*/
emit(event: string, ...args) {
if (event in this._events === false) return
for (let i = 0; i < this._events[event].length; i++) {
this._events[event][i](...args)
}
}
}