// @ts-nocheck
/** A zero dependency custom lightweight web component that builds a table from data
* Version: See the class code
*/
/** Copyright (c) 2024-2025 Julian Knight (Totally Information)
* https://it.knightnet.org.uk, https://github.com/TotallyInformation
*
* Licensed under the Apache License, Version 2.0 (the 'License');
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an 'AS IS' BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
/** TODO
* - Add row ID (R1, ...) - add col ID (C1, ...)
* - Process set cols to override auto-cols
* - allow for sparse data (use #data cols instead of #cols)
* - allow updating of specific rows or cells in the data - auto-update visuals
* - Maybe a row setter and a cell setter
* - Allow deleting of rows/columns
* - Allow moving rows/columns
* - Allow adding rows/columns
* - Add uibuilder handlers: uib-var
* - Save altered data - allow saving to browser storage
* - Custom event on changed data (data, row, cell, column)
* - tfoot
* - Multiple thead's with spans
* - cell overrides with spans
* - caption
* - table number
* - allow cell values as HTML (add reminder about using sanitise)
* - may need to allow for manual reset of column data
* - add method setValueByCellId
* - allow changes to dom to be reflected into the data
*
* These are a LOT more advanced and will take time to work out:
* - Allow dynamic styling
* - Allow dynamic data calculations
*
* QUESTIONS
* - What about using a set or map instead of array/object
* - What happens when adding both new row/col (objects)
* - What happens when pushing both new row/col (array)
*/
import TiBaseComponent from '../../libs/ti-base-component'
/** Only use a template if you want to isolate the code and CSS */
// const template = document.createElement('template')
// template.innerHTML = /*html*/`
// <style>
// :host {
// }
// /* Small screen (37.5em @ 16pt is about 600px) */
// @media all and (max-width: 37.5em) {
// :host {
// }
// }
// </style>
// <div><slot></slot></div>
// `
/** Namespace
* @namespace Alpha
*/
// Define the class and make it the default export
/**
* @class
* @augments TiBaseComponent
* @description A zero dependency custom lightweight web component that builds an HTML table from data
*
* @element smart-table
* @memberOf Alpha
* METHODS FROM BASE:
* @function config Update runtime configuration, return complete config
* @function createShadowSelectors Creates the jQuery-like $ and $$ methods
* @function deepAssign Object deep merger
* @function doInheritStyles If requested, add link to an external style sheet
* @function ensureId Adds a unique ID to the tag if no ID defined.
* @function _uibMsgHandler Not yet in use
* @function _event (name,data) Standardised custom event dispatcher
* @function _ready Call from end of connectedCallback. Sets connected prop and outputs events
* OTHER METHODS:
* @function getCellById Get an HTML element reference to a cell using `RxCx` referencing
* @function getValueByCellId Get the data value of a cell using `RxCx` referencing
* @function getValueByOffset (obj,rowOffset,colOffset) Get the data value of a cell in an tabular array/object using row/column numbers
* fires smart-table:connected - When an instance of the component is attached to the DOM. `evt.details` contains the details of the element.
* fires smart-table:ready - Alias for connected. The instance can handle property & attribute changes
* fires smart-table:disconnected - When an instance of the component is removed from the DOM. `evt.details` contains the details of the element.
* fires smart-table:attribChanged - When a watched attribute changes. `evt.details` contains the details of the change.
* NOTE that listeners can be attached either to the `document` or to the specific element instance.
* Standard watched attributes (common across all my components):
* property {string} name - Optional. HTML name attribute. Included in output _meta prop.
* Other watched attributes:
* None
* Standard props (common across all my components):
* @property {number} _iCount Static. The component version string (date updated)
* @property {boolean} uib True if UIBUILDER for Node-RED is loaded
* @property {boolean} connected False until connectedCallback finishes
* @property {string} name Placeholder for the optional name attribute
*
* @property {string} version Static. The component version string (date updated). Also has a getter that returns component and base version strings.
* Other props:
* @property {object|Array} data Data to build table. Can be an array of objects, an object of objects or an array of arrays. Must be 2d (tabular)
* @property {object} cols Override the column metadata. If not supplied, cols is built from the first entry of the data array/object
*
* @property {HTMLTableElement} elThead Reference to the thead element
* @property {HTMLTableElement} elTfoot Reference to the tfoot element
* @property {HTMLTableElement} elTbody Reference to the tbody element
* By default, all attributes are also created as properties
* @slot None, this component does not use the shadow dom
* See https://github.com/runem/web-component-analyzer?tab=readme-ov-file#-how-to-document-your-components-using-jsdoc
*/
class SmartTable extends TiBaseComponent {
/** Component version */
static componentVersion = '2024-10-06'
/** Makes HTML attribute change watched
* @returns {Array<string>} List of all of the html attribs (props) listened to
*/
static get observedAttributes() {
return [
// Standard watched attributes:
'inherit-style', 'name',
// Other watched attributes:
]
}
/** PRIVATE: A copy of the provided data @type {object|array} */
#data
/** PRIVATE: Column metadata for the table @type {object} */
#cols
/** Reference to table head @type {HTMLTableElement} */
elThead
/** Reference to table foot @type {HTMLTableElement} */
elTfoot
/** Reference to table body @type {HTMLTableElement} */
elTbody
/** Set the value to show */
set data(val) {
// console.log('set data', val)
if (val === null) return
if (val === this.#data) return
this.#data = this._createProxy(val)
// If cols not set, Build cols from 1st entry
if (!this.#cols) this._buildCols(true)
this._buildHeadings()
this._buildRows()
// this.#outputEl.textContent = val
// this.setAttribute('value', val)
}
/** Get the current shown value
* @returns {object|Array} The data used to build the table
*/
get data() {
return this.#data
}
/** NB: Attributes not available here - use connectedCallback to reference */
constructor() {
super()
// Only attach the shadow dom if code and style isolation is needed - comment out if shadow dom not required
// this._construct(template.content.cloneNode(true))
// this.#outputEl = this.shadowRoot.querySelector('output')
}
/** Runs when an instance is added to the DOM */
connectedCallback() {
this._connect() // Keep at start.
// this.label = this.getAttribute('label')
// Create the table outline structure: table, thead, tfoot, tbody
this._buildTblOutline()
this._ready() // Keep at end. Let everyone know that a new instance of the component has been connected & is ready
}
/** Runs when an instance is removed from the DOM */
disconnectedCallback() {
this._disconnect() // Keep at end.
}
// Runs when an observed attribute changes - Note: values are always strings
attributeChangedCallback(attrib, oldVal, newVal) {
/** Optionally ignore attrib changes until instance is fully connected
* Otherwise this can fire BEFORE everthing is fully connected.
*/
if (!this.connected) return
// Don't bother if the new value same as old
if ( oldVal === newVal ) return
// Create a property from the value - WARN: Be careful with name clashes
this[attrib] = newVal
// Add other dynamic attribute processing here.
// If attribute processing doesn't need to be dynamic, process in connectedCallback as that happens earlier in the lifecycle
// Keep at end. Let everyone know that an attribute has changed for this instance of the component
this._event('attribChanged', { attribute: attrib, newVal: newVal, oldVal: oldVal, })
}
/** Allows access to a cell using numeric row/col offsets
* @param {object|Array} obj The 2d object to search
* @param {number} rowOffset The row offset
* @param {number} colOffset The column offset
* @returns {*} Cell value
*/
getValueByOffset(obj, rowOffset, colOffset) {
const rowKeys = Object.keys(obj)
const colKeys = Object.keys(obj[rowKeys[rowOffset]])
const rowKey = rowKeys[rowOffset]
const colKey = colKeys[colOffset]
return obj[rowKey][colKey]
}
/** Get an element reference from an RxCx cell reference
* @param {string} cellId The RxCx cell reference required
* @returns {HTMLTableCellElement|null} The HTML table cell element if found
*/
getCellById(cellId) {
const el = this.elTbody.querySelector(`td[data-cell="${cellId}"]`)
if (!el) {
console.warn(`[${this.localName}:getCellById:${this.id}] Invalid cell reference "${cellId}"`)
}
// @ts-ignore
return el
}
/** Get the value of a data cell using RxCx referencing
* @param {string} cellId The RxCx cell reference required
* @returns {*} The value of the cell if found else Null or Undefined
*/
getValueByCellId(cellId) {
let [, row, col] = cellId.split(/^R(\d+)C(\d+)$/ig)
// @ts-ignore
row = Number(row) - 1
// @ts-ignore
col = Number(col) - 1
if (Number.isNaN(row) || Number.isNaN(col)) {
console.warn(`[${this.localName}:getValueByCellId:${this.id}] Invalid cell reference "${cellId}"`)
return null
}
// @ts-ignore
return this.getValueByOffset(this.#data, row, col)
}
_createProxy(data, parentKey = null) {
return new Proxy(data, {
get: (target, prop) => {
const value = target[prop]
// If it's an array or object (row or cell), create a proxy for it too
if (Array.isArray(value) || (typeof value === 'object' && value !== null)) {
// Prevent going >1 level deep - e.g. only deal with 2d data
if (parentKey !== null) {
console.warn(`[${this.localName}:_createProxy:${this.id}] Data is >2d. Can only deal with 2d (tabular) data.`)
return value
}
return this._createProxy(value, prop)
}
return value
},
set: (target, prop, value) => {
const isNewProperty = !(prop in target) // Detect addition
// Intercept changes to rows or cells
target[prop] = value
if (isNewProperty) {
// console.log(`New property added: ${parentKey ? parentKey + '.' : ''}${String(prop)}`)
this._onDataChange(parentKey, prop, value, 'added') // Handle addition
} else {
// console.log(`Data at ${parentKey}.${String(prop)} has changed to`, value)
this._onDataChange(parentKey, prop, value, 'modified') // Handle modification
}
return true
},
deleteProperty: (target, prop) => {
const deletedValue = target[prop]
const success = delete target[prop]
if (success) {
// console.log(`Property deleted: ${parentKey ? parentKey + '.' : ''}${String(prop)}`)
this._onDataChange(parentKey, prop, deletedValue, 'deleted') // Handle deletion
}
return success
},
})
}
// Method to handle data changes (cell or row)
_onDataChange(parentKey, prop, value, changeType) {
// Perform any actions needed when a cell or row changes
// if prop === 'length' then an array has changed
// BUT objects don't report on size changes
// if (parentKey === null) {
// console.log(`ROW ${changeType}: Row: ${prop}, Value: `, value)
// } else {
// console.log(`CELL ${changeType}: Row: ${parentKey}, Column: ${prop}, Value: `, value)
// }
// TODO REPLACE this total rebuild with something more nuanced
this.elThead.replaceChildren('')
this.elTfoot.replaceChildren('')
this.elTbody.replaceChildren('')
// If cols not set, Build cols from 1st entry
if (!this.#cols) this._buildCols(true)
this._buildHeadings()
this._buildRows()
}
/** Builds the table outline and prepends to the component tag */
_buildTblOutline() {
const tblEl = document.createElement('table')
tblEl.innerHTML = /*html*/`
<thead></thead>
<tfoot></tfoot>
<tbody></tbody>
`
this.prepend(tblEl)
// Get references to the various table parts for convenience
this.elThead = tblEl.getElementsByTagName('thead')[0]
this.elTfoot = tblEl.getElementsByTagName('tfoot')[0]
this.elTbody = tblEl.getElementsByTagName('tbody')[0]
}
/** Build the column metadata
* @param {boolean} fromData If true, calculate the columns from the data
*/
_buildCols(fromData) {
this.#cols = {}
const firstRow = this.#data[Object.keys(this.#data)[0]]
// If 1st row is an array - simulate column names
if ( Array.isArray(firstRow) ) {
firstRow.forEach( (col, i) => {
const colname = `C${i + 1}`
this.#cols[colname] = {
id: colname,
name: `Column ${i + 1}`,
type: typeof col,
title: undefined,
}
})
} else {
Object.keys(firstRow).forEach( col => {
this.#cols[col] = {
id: col,
name: col,
type: typeof col,
title: undefined,
}
})
}
// console.log(this.id, this.#cols )
}
_buildHeadings() {
const rowHeadId = 'H1R1'
const rowEl = document.createElement('tr')
rowEl.dataset.row = rowHeadId
Object.keys(this.#cols).forEach( (col, i) => {
// console.log(i, col)
const colEl = document.createElement('th')
colEl.dataset.row = rowHeadId
colEl.dataset.col = this.#cols[col].id
colEl.innerText = this.#cols[col].name
rowEl.appendChild(colEl)
})
this.elThead.appendChild(rowEl)
}
_buildRows() {
Object.keys(this.#data).forEach( (row, i) => {
this._buildRow(i, row, this.#data[row])
})
}
_buildRow(i, row, rowData) {
const rowNum = Number(row)
const rowId = Number.isNaN(rowNum) ? row : `R${rowNum + 1}`
const rowEl = document.createElement('tr')
rowEl.dataset.row = rowId
// console.log(this.id, i, row, rowId, this.#data[row])
Object.keys(this.#cols).forEach( (col, j) => {
// console.log(i, col)
const colEl = document.createElement('td')
colEl.dataset.row = rowId
colEl.dataset.col = this.#cols[col].id
// colEl.dataset.cell = `${rowId}:${this.#cols[col].id}`
colEl.dataset.cell = `R${i + 1}C${j + 1}`
colEl.innerText = this.#data[row][col] === undefined ? this.#data[i][j] : this.#data[row][col]
rowEl.appendChild(colEl)
})
this.elTbody.appendChild(rowEl)
}
} // ---- end of Class ---- //
// Make the class the default export so it can be used elsewhere
export default SmartTable
/** Self register the class to global
* Enables new data lists to be dynamically added via JS
* and lets the static methods be called
*/
window['SmartTable'] = SmartTable
// Self-register the HTML tag
customElements.define('smart-table', SmartTable)