UNPKG

simple-datatables

Version:

A lightweight, dependency-free JavaScript HTML table plugin.

1,147 lines (965 loc) 39.8 kB
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) } } }