@es-labs/node
Version:
Reusable library
757 lines (688 loc) • 26.2 kB
JavaScript
// TODO
// inline edit?
// FEATURES
// handle columns and items
// row select
// pagination (optional)
// filters (optional)
// sorter single column (optional)
// checkbox (optional)
// sticky header (optional)
// sticky coloumn (optional - currently only for 1st column)
// checkbox & check all (optional)
// custom render columns
// STYLING...
// --bwc-table-width: 100%
// --bwc-table-overflow: auto
// --bwc-table-height: 100%
// --bwc-table-navbar-bgcolor: white
// --bwc-table-filter-bgcolor: white
// --bwc-table-filter-color: black
// --bwc-table-filter-top: 56px
// --bwc-table-th-bgcolor: white
// --bwc-table-th-color: black
// --bwc-table-td-bgcolor: transparent
// --bwc-table-td-color: black
// --bwc-table-td-select-bgcolor: black
// --bwc-table-td-select-color: black
// --bwc-table-sticky-header-top: 56px
// PROPERTIES
// commands="reload,filter"
// :pagination="true"
// :sort="true"
// :page="page"
// :pageSize="pageSize"
// :pageSizeList="pageSizeList"
// :columns="columns"
// :items="table.items"
// :total="total"
// style="--bwc-table-height: calc(100vh - 360px);--bwc-table-width: 200%;"
// class="sticky-header sticky-column"
// TODO change some properties to attributes? handle multiple UI frameworks
// EVENTS
// rowclick { detail: { row, col, data }
// triggered = sort / page / page-size / reload { detail: { name, sortKey, sortDir, page, pageSize, filters: [ { key, op, val, andOr } ] } }
// cmd = show/hide filter, reload, add, del, import, export, goback (if parentKey != null)
// checked = [indexes checked...]
// COLUMN PROPERTIES
// for hidden table columns, please remove before passing it to component
// label: 'ID',
// key: 'id',
// filter: false,
// sort: false,
// render: ({val, key, row, idx}) => `<a class='button' onclick='this.dispatchEvent(new CustomEvent("testevent", { detail: ${JSON.stringify({ val, key, row, idx })} }))'>${val}</a>`
// cell value, column key, row data, row index (0-based)
// try not to include row property in event detail... can be too much data
// NOT NEEDED
// loading state and loading spinner
// NOTES
// do not use document.querySelector, use this.querySelector
const template = document.createElement('template')
template.innerHTML = /*html*/`
<style>
#table-wrapper {
overflow: var(--bwc-table-overflow, auto);
height: var(--bwc-table-height, 100%);
}
#table-wrapper table {
table-layout: initial;
width: var(--bwc-table-width, 100%);
}
#table-wrapper > nav {
position: -webkit-sticky;
position: sticky;
top: 0px;
left: 0px;
z-index: 2;
background-color: var(--bwc-table-navbar-bgcolor, lightslategray) !important;
}
#table-wrapper #filters {
position: -webkit-sticky;
position: sticky;
top: var(--bwc-table-filter-top, 56px);
left: 0px;
z-index: 2;
background-color: var(--bwc-table-filter-bgcolor, white);
color: var(--bwc-table-filter-color, black);
}
#table-wrapper th {
background-color: var(--bwc-table-th-bgcolor, white);
color: var(--bwc-table-th-color, black);
}
#table-wrapper tr td {
background-color: var(--bwc-table-td-bgcolor, transparent);
color: var(--bwc-table-td-color, black);
}
#table-wrapper tr.is-selected td {
background-color: var(--bwc-table-td-select-bgcolor, lightgrey);
color: var(--bwc-table-td-select-color, black);
}
.sticky-header #table-wrapper th {
position: -webkit-sticky;
position: sticky;
top: var(--bwc-table-sticky-header-top, 56px); /* nav height - TODO filter height*/
z-index: 2;
}
.sticky-column #table-wrapper th[scope=row] {
position: -webkit-sticky;
position: sticky;
left: 0;
z-index: 3;
}
.sticky-column #table-wrapper th:not([scope=row]) {
}
.sticky-column #table-wrapper td[scope=row] {
position: -webkit-sticky;
position: sticky;
left: 0;
z-index: 1;
}
input::-webkit-outer-spin-button, /* to remove up and down arrows */
input::-webkit-inner-spin-button {
-webkit-appearance: none;
margin: 0;
}
input[type="number"] {
-moz-appearance: textfield;
}
</style>
<div id="table-wrapper">
<nav id="table-navbar" class="navbar" role="navigation" aria-label="main navigation">
<div class="navbar-brand">
<a id="table-navbar-burger" role="button" class="navbar-burger burger" aria-label="menu" aria-expanded="false" data-target="table-navbar-menu">
<span aria-hidden="true"></span>
<span aria-hidden="true"></span>
<span aria-hidden="true"></span>
</a>
</div>
<div id="table-navbar-menu" class="navbar-menu">
<div class="navbar-start">
<div id="commands" class="navbar-item">
<a id="cmd-goback" class="button">↶</a>
<a id="cmd-filter" class="button">o</a><!-- need to make this configurable -->
<a id="cmd-reload" class="button">↻</a>
<a id="cmd-add" class="button">+</a>
<a id="cmd-del" class="button">-</a>
<a id="cmd-import" class="button">↑</a>
<a id="cmd-export" class="button">↓</a>
</div>
</div>
<div class="navbar-end pagination">
<div class="navbar-item">
<a id="page-dec" class="button"><</a>
<a><input id="page-input" class="input" type="number" min="1" style="width: auto;"/></a>
<a class="button is-static"> / <span id="pages-span"></span></a>
<a id="page-inc" class="button">></a>
</div>
<div class="navbar-item">
<a>
<span class="select">
<select id="page-select">
</select>
</span>
</a>
<a class="button is-static">Rows/Page</a>
</div>
</div>
</div>
</nav>
<div id="filters"></div>
</div>
`
class Table extends HTMLElement {
// basic
#columns = []
#items = []
// enable pagination
#pagination = true
#page = 1 // one based index
#pageSize = 10
#pageSizeList = [5, 10, 15]
#pages = 0 // computed Math.ceil(total / pageSize)
#total = 0
// enable sorting
#sort = true
#sortKey = ''
#sortDir = '' // blank, asc, desc
// checkbox
#checkboxes = true
#checkedRows = []
// selected
#selectedIndex = -1
#selectedNode = null
#selectedItem = null
// enable commands menu
#commands = ''
// filters
#filters = []
#filterCols = []
#filterOps = ['=', 'like', '!=', '>=', '>', '<=', '<']
#filterShow = false
// heights
#navbarHeight = 56 // #table-navbar
#filterHeight = 0 // #filters
constructor() {
super()
// this.input = this.input.bind(this)
}
_setHeights () {
// console.log(this.#navbarHeight, this.#filterHeight)
const el = this.querySelector('#filters')
if (!el) return
el.style.top = `${this.#navbarHeight}px`
const nodes = this.querySelectorAll('.sticky-header #table-wrapper th')
for (let i = 0; i<nodes.length; i++) {
// console.log('nodes', nodes[i])
nodes[i].style.top = `${this.#navbarHeight + this.#filterHeight}px`
}
}
_eventPageInputEL(e) {
const page = Number(e.target.value)
if (page >= 1 && page <= this.#pages && Number(page) !== Number(this.page)) {
this.page = page
this._trigger('page')
} else {
this._renderPageInput()
}
}
connectedCallback() {
console.log('connected callback')
// console.log(this.value, this.required, typeof this.required)
this.appendChild(template.content.cloneNode(true))
// this.querySelector('input').addEventListener('input', this.input)
// if (this.required !== null) el.setAttribute('required', '')
// Check for click events on the navbar burger icon
this.querySelector('.navbar-burger').onclick = () => {
// Toggle the "is-active" class on both the "navbar-burger" and the "navbar-menu"
this.querySelector('#table-navbar-burger').classList.toggle('is-active') // navbar-burger
this.querySelector('#table-navbar-menu').classList.toggle('is-active') // navbar-menu
}
this.querySelector('#page-input').onkeypress = (e) => {
e.code === 'Enter' && this._eventPageInputEL(e)
}
this.querySelector('#page-input').onblur = (e) => {
this._eventPageInputEL(e)
}
this.querySelector('#cmd-filter').onclick = () => {
this.#filterShow = !this.#filterShow
this.querySelector('#filters').style.display = this.#filterShow ? 'block': 'none'
}
new ResizeObserver(entries => {
this.#navbarHeight = entries[0].target.clientHeight
this._setHeights()
}).observe(this.querySelector('#table-navbar'))
new ResizeObserver(entries => {
this.#filterHeight = entries[0].target.clientHeight
this._setHeights()
}).observe(this.querySelector('#filters')) // start observing a DOM node
this.querySelector('#cmd-reload').onclick = () => this._trigger('reload')
this.querySelector('#cmd-goback').onclick = () => this.dispatchEvent(new CustomEvent('cmd', { detail: { cmd: 'goback' } }))
this.querySelector('#cmd-add').onclick = () => this.dispatchEvent(new CustomEvent('cmd', { detail: { cmd: 'add' } }))
this.querySelector('#cmd-del').onclick = () => this.dispatchEvent(new CustomEvent('cmd', { detail: { cmd: 'del', checkedRows: this.#checkedRows } }))
this.querySelector('#cmd-import').onclick = () => this.dispatchEvent(new CustomEvent('cmd', { detail: { cmd: 'import' } }))
this.querySelector('#cmd-export').onclick = () => this.dispatchEvent(new CustomEvent('cmd', { detail: { cmd: 'export', checkedRows: this.#checkedRows } }))
this.querySelector('#page-dec').onclick = (e) => {
let numPage = Number(this.page)
if (numPage > 1 && numPage <= this.#pages) {
numPage -= 1
this.page = numPage
this._trigger('page')
}
}
this.querySelector('#page-inc').onclick = (e) => {
// console.log('inc page', this.page, this.#pages)
let numPage = Number(this.page)
if (numPage < this.#pages) {
numPage += 1
this.page = numPage
this._trigger('page')
}
}
this.querySelector('#page-select').onchange = (e) => {
this.pageSize = e.target.value
this._trigger('page-size')
if (this.page > this.#pages){
this.page = this.#pages
this._trigger('page-size')
}
}
// console.log('connectedCallback 0')
// initialize non-required properties that are undefined
if (!this.#sortKey) this.#sortKey = ''
if (!this.#sortDir) this.#sortDir = ''
this.querySelector('#filters').style.display = this.#filterShow ? 'block': 'none'
if (!this.#pagination) this.querySelector('.pagination').style.display = 'none'
if (!this.#commands || typeof this.#commands !== 'string') {
this.querySelector('#commands').style.display = 'none'
}
else {
this.querySelector('#cmd-reload').style.display = this.#commands.includes('reload') ? 'block' : 'none'
this.querySelector('#cmd-filter').style.display = this.#commands.includes('filter') ? 'block' : 'none'
this.querySelector('#cmd-add').style.display = this.#commands.includes('add') ? 'block' : 'none'
this.querySelector('#cmd-del').style.display = this.#commands.includes('del') ? 'block' : 'none'
this.querySelector('#cmd-import').style.display = this.#commands.includes('import') ? 'block' : 'none'
this.querySelector('#cmd-export').style.display = this.#commands.includes('export') ? 'block' : 'none'
this.querySelector('#cmd-goback').style.display = this.#commands.includes('goback') ? 'block' : 'none'
}
this._render()
this._renderPageSelect()
this._renderPageInput()
this._renderPages()
this._renderFilters()
// console.log('connectedCallback 1')
}
disconnectedCallback() {
// this.querySelector('input').removeEventListener('input', this.input)
}
// attributeChangedCallback(name, oldVal, newVal) {
// switch (name) {
// case 'page': { break }
// }
// }
// static get observedAttributes() {
// return ['page']
// }
get checkboxes () { return this.#checkboxes }
set checkboxes (val) { this.#checkboxes = val }
get pagination () { return this.#pagination }
set pagination (val) { this.#pagination = val }
get commands () { return this.#commands }
set commands (val) { this.#commands = val }
get sort () { return this.#sort }
set sort (val) { this.#sort = val }
get page () { return this.#page }
set page (val) { this.#page = val } // DONE ELSEWHERE emit event
get pageSize () { return this.#pageSize }
set pageSize (val) {
console.log('set pageSize', this.total , this.pageSize)
this.#pageSize = val
this._renderPages()
} // DONE ELSEWHERE emit event
get pageSizeList () { return this.#pageSizeList }
set pageSizeList (val) { this.#pageSizeList = val } // TODO emit event
get items() { return this.#items }
set items(val) {
// console.log('set items')
this.#items = val
this._render()
this._renderPageSelect()
this._renderPageInput()
this._renderPages()
} // if columns do something
get total () { return this.#total }
set total (val) {
this.#total = val
this._renderPages()
} // emit event ?
get selectedItem () { return this.#selectedItem }
set selectedItem (val) { this.#selectedItem = val }
get columns() { return this.#columns }
set columns(val) {
this.#columns = val
this._render()
}
_renderPages () {
this.#pages = Math.ceil(this.total / this.pageSize)
const el = this.querySelector('#pages-span')
if (el) el.textContent = this.#pages
}
_renderPageSelect () {
const el = this.querySelector('#page-select')
if (!el) return
el.textContent = '' // remove all children
this.pageSizeList.forEach(item => {
const option = document.createElement('option')
option.value = item
option.textContent = item
if (Number(item) === Number(this.pageSize)) option.selected = true
el.appendChild(option)
})
}
_renderPageInput () {
const el = this.querySelector('#page-input')
if (!el) return
el.value = this.page
}
_createSelect (items, filter, prop) {
const p = document.createElement('p')
p.classList.add('control', 'm-0')
const span = document.createElement('span')
span.classList.add('select')
const select = document.createElement('select')
items.forEach(item => {
const option = document.createElement('option')
if (item.key) {
option.textContent = item.label
option.value = item.key
} else {
option.textContent = item
option.value = item
}
select.appendChild(option)
})
select.value = filter[prop]
select.onchange = e => filter[prop] = e.target.value
span.appendChild(select)
p.appendChild(span)
return p
}
_renderFilters () {
const el = this.querySelector('#filters')
el.textContent = ''
if (this.#filters.length) {
for (let i=0; i < this.#filters.length; i++) {
const filter = this.#filters[i]
const div = document.createElement('div')
div.classList.add('field', 'has-addons', 'm-0', 'p-1')
div.appendChild( this._createSelect (this.#filterCols, filter, 'key') ) // TODO set input type and pattern based on column UI change event
div.appendChild( this._createSelect (this.#filterOps, filter, 'op') )
const p = document.createElement('p')
p.classList.add('control', 'm-0')
const filterInput = document.createElement('input')
filterInput.classList.add('input')
filterInput.value = filter.val
filterInput.oninput = e => filter.val = e.target.value // so that we can keep the filter value
p.appendChild(filterInput)
div.appendChild(p)
const pf = document.createElement('p')
pf.classList.add('control', 'm-0')
pf.innerHTML = `<span class="select">
<select id="filter-and-or">
<option value="and">And</option>
<option value="or">Or</option>
</select>
</span>`
pf.querySelector('#filter-and-or').value = filter.andOr
pf.querySelector('#filter-and-or').onchange = e => filter.andOr = e.target.value
div.appendChild(pf)
const p1 = document.createElement('p')
p1.classList.add('control', 'm-0')
const delBtn = document.createElement('button')
delBtn.classList.add('button')
delBtn.textContent = '-'
delBtn.onclick = () => this._delFilter(i)
p1.appendChild(delBtn)
div.appendChild(p1)
const p2 = document.createElement('p')
p2.classList.add('control', 'm-0')
const addBtn = document.createElement('button')
addBtn.classList.add('button')
addBtn.textContent = '+'
addBtn.onclick = () => this._addFilter(i + 1)
p2.appendChild(addBtn)
div.appendChild(p2)
el.appendChild(div)
}
} else {
const div = document.createElement('div')
div.classList.add('field', 'p-1')
const p = document.createElement('p')
p.classList.add('control')
const btn = document.createElement('button')
btn.classList.add('button')
btn.textContent = '+'
btn.onclick = () => this._addFilter(0)
p.appendChild(btn)
div.appendChild(p)
el.appendChild(div)
}
}
_trigger (name) {
const filters = []
const el = this.querySelector('#filters')
for (let i=0; i<el.children.length; i++) {
const div = el.children[i]
if (div.children.length >= 4) {
filters.push({
key: div.children[0].querySelector('select').value,
op: div.children[1].querySelector('select').value,
val: div.children[2].querySelector('input').value,
andOr: div.children[3].querySelector('select').value
})
}
}
this.dispatchEvent(new CustomEvent('triggered', {
// get filter information
detail: {
name, // page, sort
sortKey: this.#sortKey,
sortDir: this.#sortDir,
page: this.page || 0,
pageSize: this.pageSize || 0,
filters
}
}))
}
// filters
_delFilter (index) {
this.#filters.splice(index, 1) // console.log('remove filter', index)
this._renderFilters()
}
_addFilter (index) {
this.#filters.splice(index, 0, { key: this.#filterCols[0].key, label: this.#filterCols[0].label, op: this.#filterOps[0], val: '', andOr: 'and' })
this._renderFilters()
}
_render() {
// console.log('bwc-table render fired')
try {
const el = this.querySelector('#table-wrapper')
if (!el) return
//<tfoot><tr><th><abbr title="Position">Pos</abbr></th>
let table = el.querySelector('table')
if (table) {
// const cNode = table.cloneNode(false)
// table.parentNode.replaceChild(cNode, table)
// table.innerHTML = ''
const parent = el.querySelector('table') // WORKS!
while (parent.firstChild) {
parent.firstChild.remove()
}
parent.remove()
}
if (this.#columns && typeof this.#columns === 'object') {
// console.log('render thead')
table = document.createElement('table')
table.setAttribute('id', 'table')
el.appendChild(table)
const thead = document.createElement('thead')
thead.onclick = (e) => {
let target = e.target
if (this.#checkboxes && !target.cellIndex) { // checkbox clicked - target.type === 'checkbox' // e.stopPropagation()?
this.#checkedRows = [] // clear first
const tbody = this.querySelector('table tbody')
if (tbody && tbody.children) {
for (let i = 0; i < tbody.children.length; i++) {
const tr = tbody.children[i]
const td = tr.firstChild
if (td) {
const checkbox = td.firstChild
if (checkbox.type === 'checkbox') {
checkbox.checked = target.checked
if (target.checked) this.#checkedRows.push(i)
}
}
}
}
this.dispatchEvent(new CustomEvent('checked', { detail: this.#checkedRows }))
} else { // sort
if (!this.sort) return
const offset = this.#checkboxes ? 1 : 0 // column offset
const col = target.cellIndex - offset // TD 0-index based column
if (!this.#columns[col].sort) return
const key = this.#columns[col].key
if (key !== this.#sortKey) {
this.#sortKey = key
this.#sortDir = 'asc'
} else {
if (this.#sortDir === 'asc') {
this.#sortDir = 'desc'
} else if (this.#sortDir === 'desc') {
this.#sortKey = ''
this.#sortDir = ''
}
}
this._trigger('sort') // header is re-rendered, checkboxes are also cleared...
}
}
table.appendChild(thead)
table.classList.add('table')
const tr = document.createElement('tr')
thead.appendChild(tr)
if (this.#checkboxes) { // check all
const th = document.createElement('th')
th.style.width = '50px' // TODO do not hardcode
const checkbox = document.createElement('input')
checkbox.type = 'checkbox' // value
th.setAttribute('scope', 'row')
th.appendChild(checkbox)
tr.appendChild(th)
}
this.#filterCols = [] // clear this first
for (const col of this.#columns) {
const th = document.createElement('th')
if (col.sort) th.style.cursor = 'pointer'
let label = col.label
if (col.sort) {
if (this.#sortKey === col.key) {
// ∧ (up) & ∨ (down)
label += this.#sortDir === 'asc' ? '↑' : (this.#sortDir === 'desc' ? '↓' : '↕')
} else {
label += '↕'
}
}
if (col.width) th.style.width = `${col.width}px`
if (col.sticky) th.setAttribute('scope', 'row')
th.appendChild(document.createTextNode(label))
tr.appendChild(th)
// set filters...
if (col.filter) this.#filterCols.push({
key: col.key,
label: col.label
}) // process filters (col is key)
}
// populate the data
if (this.#items && typeof this.#items === 'object' && this.#items.length) {
// console.log('render tbody')
const tbody = document.createElement('tbody')
// TODO function to get checked rows...
tbody.onclick = (e) => {
let target = e.target
if (this.#checkboxes && !target.cellIndex) { // checkbox clicked - target.type === 'checkbox' // e.stopPropagation()?
if (target.type === 'checkbox') {
this.#checkedRows = [] // clear first
for (let i = 0; i < tbody.children.length; i++) {
const tr = tbody.children[i]
const td = tr.firstChild
if (td) {
const checkbox = td.firstChild
if (checkbox.type === 'checkbox' && checkbox.checked) {
this.#checkedRows.push(i)
}
}
}
this.dispatchEvent(new CustomEvent('checked', { detail: this.#checkedRows }))
}
} else {
const offset = this.#checkboxes ? 1 : 0 // column offset
const col = target.cellIndex - offset // TD 0-index based column
while (target && target.nodeName !== "TR") {
target = target.parentNode
}
const row = target.rowIndex - 1 // TR 1-index based row
let data = null
if (target) { // TODO - To handle multiple UI frameworks
if (this.#selectedNode) { // clear class is-selected
this.#selectedNode.classList.remove('is-selected')
}
if (this.#selectedIndex === row && this.#selectedIndex !== -1) { // unselect
this.#selectedIndex = -1
this.selectedItem = null
} else {
data = { ...this.#items[row] }
this.#selectedNode = target // set selected
this.#selectedIndex = row
this.selectedItem = { row, col, data }
target.classList.add('is-selected')
}
}
this.dispatchEvent(new CustomEvent('rowclick', { detail: { row, col, data } }))
}
}
table.appendChild(tbody)
for (const [idx, row] of this.#items.entries()) {
const tr = document.createElement('tr')
tbody.appendChild(tr)
if (this.#checkboxes) { // add checkbox
const td = document.createElement('td')
const checkbox = document.createElement('input')
checkbox.type = 'checkbox' // value
td.setAttribute('scope', 'row')
td.appendChild(checkbox)
tr.appendChild(td)
}
for (const col of this.#columns) {
const { key, sticky, width, render } = col
const td = document.createElement('td')
// if (sticky) td.setAttribute('scope', 'row') // not used yet, need to calculate left property value
if (width) td.style.width = `${width}px`
if (render) {
td.innerHTML = render({
val: row[key],
key,
row,
idx
}) // value, key, row - need to sanitize, el (the td element)
} else {
td.appendChild(document.createTextNode(row[key]))
}
tr.appendChild(td)
}
}
}
}
} catch (e) {
console.log(e)
}
}
}
customElements.define('bwc-table', Table)