Skip to content

Instantly share code, notes, and snippets.

@mmkathurima
Last active May 12, 2024 08:50
Show Gist options
  • Save mmkathurima/c42bad2909cca37d4273fec5ba68fdf7 to your computer and use it in GitHub Desktop.
Save mmkathurima/c42bad2909cca37d4273fec5ba68fdf7 to your computer and use it in GitHub Desktop.
Simple table plugin with pure JavaScript. Allows sorting, filtering, searching, pagination, hiding of columns, exporting to clipboard, csv and excel.
table, th, td {
border: 1px solid;
}
table {
margin: 0;
width: 100%;
table-layout: fixed;
}
table th {
font-weight: bold;
resize: horizontal;
overflow: auto;
word-wrap: break-word;
user-select: none;
}
table td {
padding-left: 0.3em;
text-overflow: ellipsis;
overflow: hidden;
word-wrap: break-word;
}
.active {
background-color: lightgrey;
}
input[type=search] {
width: 90%;
}
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Custom Table</title>
<link rel="stylesheet" href="custom_table.css">
</head>
<body>
<table id="tbl">
<thead>
<tr>
<th>Title</th>
<th>Year</th>
<th>Fake Release</th>
<th>Cast</th>
<th>Genres</th>
<th>Link</th>
<th>Extract</th>
</tr>
</thead>
</table>
</body>
<script src="custom_table.js"></script>
<script src="https://momentjs.com/downloads/moment.js"></script>
<script lang="javascript" src="https://cdn.sheetjs.com/xlsx-0.20.1/package/dist/xlsx.full.min.js"></script>
<script>
document.addEventListener("DOMContentLoaded", async function (e) {
const data = await fetch("./data/movies.json");
initializeTable("tbl", await data.json(),
[{col: "title"}, {col: "year"},
{
col: "fakeRelease",
dateFormat: "yyyy",
//asNumber: true,
display: function (data, item) {
return item.year;
}
},
{col: "cast"}, {col: "genres"}, {
col: "href",
display: function (data, item) {
if (data)
return `<a href='https://en.wikipedia.org/wiki/${data}'>${data}</a>`;
return "";
}
}, {
col: "extract", display(data) {
if (!data)
return "No data";
return data;
}
}]);
});
/*
TODO:
//pagination
//filtering
ordering columns - https://phuoc.ng/collection/html-dom/drag-and-drop-table-column/
//sorting columns
//resizing columns
//export to excel, csv
//toggle column visibility
*/
</script>
</html>
function initializeTable(id, data, columns) {
const tbl = document.getElementById(id);
const tbody = document.createElement("tbody");
const itemsPPage = document.createElement("select");
const headers = [...tbl.querySelectorAll("tr th")];
const colHeaderMapping = columns.map((x, i) => ({name: x.col, text: headers[i].textContent}));
const paginationDiv = document.createElement("div");
const invisibleColumns = [];
let sortCol, sortAsc = true;
window.page = 1;
window.items = data;
function setupData(start, end, items = data) {
window.items = items;
const tableBodies = tbl.getElementsByTagName("tbody");
if (tableBodies.length !== 0) {
for (const tb of [...tableBodies])
tb.innerHTML = "";
} else tbody.innerHTML = "";
let tr, td;
const sliced = items.slice(start, end);
for (let i = 0; i < sliced.length; i++) {
const dt = sliced[i];
tr = document.createElement("tr");
for (const item of columns) {
td = document.createElement("td");
if (item.display)
td.innerHTML = String(item.display(dt[item.col], dt));
else
td.textContent = String(dt[item.col]);
tr.appendChild(td);
}
tbody.appendChild(tr);
}
tbl.appendChild(tbody);
}
function setupLimits() {
itemsPPage.id = "items-per-page";
for (const opt of [10, 25, 50]) {
const option = document.createElement("option")
option.value = String(opt);
option.label = String(opt);
itemsPPage.add(option);
}
itemsPPage.addEventListener("change", function (ev) {
setupData(0, ev.target.value, window.items);
setupPagination(window.page, window.items);
removeInvisibleColumns();
});
tbl.parentElement.insertBefore(itemsPPage, tbl);
}
function setupPagination(page, items = data) {
window.items = items;
const size = items.length;
const paginated = 10;
window.page = page;
const maxPages = Math.ceil(size / itemsPPage.value);
if (window.page < 1)
window.page = 1;
else if (window.page > maxPages)
window.page = maxPages;
let start = Math.floor(window.page / paginated) * paginated;
let end = Math.ceil(window.page / paginated) * paginated;
start = start === 0 ? 1 : start;
end = Math.min(end, maxPages);
if (window.page === end && window.page !== maxPages) {
const remainingPages = maxPages - window.page;
if (remainingPages !== paginated)
end += remainingPages;
else end += paginated;
}
paginationDiv.innerHTML = "";
let btn;
if (window.page > 1) {
btn = document.createElement("button");
btn.textContent = "<<";
btn.addEventListener("click", function (e) {
navigate(1, items);
});
paginationDiv.appendChild(btn);
btn = document.createElement("button");
btn.textContent = "<";
btn.addEventListener("click", function (e) {
navigate(--window.page, items);
});
paginationDiv.appendChild(btn);
}
for (let i = start; i <= end; i++) {
btn = document.createElement("button");
btn.textContent = String(i);
btn.addEventListener("click", function (e) {
navigate(i, items);
});
if (i === window.page) {
btn.classList.add("active");
} else btn.classList.remove("active");
paginationDiv.appendChild(btn);
}
if (window.page < maxPages) {
btn = document.createElement("button");
btn.textContent = ">";
btn.addEventListener("click", function (e) {
navigate(++window.page, items);
});
paginationDiv.appendChild(btn);
btn = document.createElement("button");
btn.textContent = ">>";
btn.addEventListener("click", function (e) {
navigate(maxPages, items);
console.log(window.page);
});
paginationDiv.appendChild(btn);
}
function navigate(pg, items = data) {
window.items = items;
window.page = pg;
setupPagination(pg, items);
const max = pg * itemsPPage.value;
setupData(max - itemsPPage.value, max, items);
removeInvisibleColumns();
}
const navigationData = document.createElement("p");
let p = document.createElement("div");
p.textContent = `Page ${window.page.toLocaleString()} of ${maxPages.toLocaleString()}.`;
navigationData.appendChild(p);
p = document.createElement("div");
p.textContent = `${size.toLocaleString()} records.`
navigationData.appendChild(p);
paginationDiv.appendChild(navigationData);
}
function setupSorting(e, items = data) {
window.items = items;
const tblHeaders = [...tbl.querySelectorAll("tr th")];
let selectedHeaderIndex = tblHeaders.indexOf(e.target);
if (selectedHeaderIndex === -1) {
if (tblHeaders.includes(e.target.parentElement))
selectedHeaderIndex = tblHeaders.indexOf(e.target.parentElement);
else return;
}
const selectedHeader = tblHeaders[selectedHeaderIndex];
const thisSort = columns[selectedHeaderIndex].col;
let order = document.getElementById(thisSort + "Header");
if (sortCol === thisSort) sortAsc = !sortAsc;
sortCol = thisSort;
if (!order) {
order = document.createElement("span");
order.id = thisSort + "Header";
for (let i = 0; i < columns.length; i++) {
const orderCol = document.getElementById(columns[i].col + "Header");
if (orderCol)
orderCol.parentElement.removeChild(orderCol);
}
selectedHeader.insertBefore(order, selectedHeader.childNodes[1]);
}
if (sortAsc) order.textContent = '▼';
else order.textContent = '▲';
const max = window.page * itemsPPage.value;
setupData(max - itemsPPage.value, max, items.sort((a, b) => {
if (a[sortCol] < b[sortCol]) return sortAsc ? 1 : -1;
if (a[sortCol] > b[sortCol]) return sortAsc ? -1 : 1;
return 0;
}));
removeInvisibleColumns();
}
function setupColumnVisibility() {
const cols = columns.map(x => x.col);
const checkDivParent = document.createElement("div");
const checkDiv = document.createElement("div");
checkDiv.style.overflowX = "auto";
for (let i = 0; i < cols.length; i++) {
const check = document.createElement("input");
check.type = "checkbox";
check.checked = true;
check.id = cols[i];
check.addEventListener("input", function (ev) {
const checked = ev.target.checked;
if (!checked) {
headers[i].style.display = "none";
tbody.querySelectorAll(`tr td:nth-child(${i + 1})`).forEach(x => x.style.display = "none");
if (!invisibleColumns.includes(ev.target.id))
invisibleColumns.push(ev.target.id);
console.log(invisibleColumns);
}
if (checked) {
headers[i].style.display = "table-cell";
tbody.querySelectorAll(`tr td:nth-child(${i + 1})`).forEach(x => x.style.display = "table-cell");
removeItem(invisibleColumns, ev.target.id);
console.log(invisibleColumns);
}
});
const checkLbl = document.createElement("label");
checkLbl.htmlFor = cols[i];
checkLbl.textContent = headers[i].textContent;
checkDiv.appendChild(check);
checkDiv.appendChild(checkLbl);
checkDivParent.appendChild(checkDiv);
}
itemsPPage.parentNode.insertBefore(checkDivParent, itemsPPage.nextSibling);
}
function removeInvisibleColumns() {
for (let i = 0; i < columns.length; i++) {
if (invisibleColumns.includes(columns[i].col))
tbody.querySelectorAll(`tr td:nth-child(${i + 1})`).forEach(x => x.style.display = "none");
}
}
function setupFilter(items = data) {
window.items = items;
const colMapping = columns.map((x, i) => ({name: x, header: headers[i]}));
const numeric = [], dates = [];
let inputContainer = document.createElement("div");
const expressionBuilder = {}, expressionBuilder2 = {};
for (const dt of items) {
for (const item of columns) {
let d = dt[item.col];
if (item.display)
d = item.display(d, dt);
if (item.dateFormat && !dates.some(x => x.column === item.col))
dates.push({
column: item.col,
format: item.dateFormat
});
if (item.dateFormat) {
const fmt = moment(d, item.dateFormat).toDate();
if (fmt)
dt[item.col] = fmt;
}
if (item.asNumber)
dt[item.col] = Number.parseFloat(d);
}
}
for (const obj of items) {
for (const key in obj) {
if ((typeof obj[key] === 'number' || isNumeric(obj[key])) && !dates.some(x => x.column === key)) {
if (!numeric.includes(key))
numeric.push(key);
} else if (numeric.includes(key))
removeItem(numeric, key);
}
}
console.log(numeric, dates);
const dateCols = dates.map(x => x.column);
for (let i = 0; i < headers.length; i++) {
const header = headers[i];
const key = colMapping.find(y => y.header === header).name.col;
let fmt;
inputContainer = document.createElement("div");
const input = document.createElement("input");
input.type = "search";
if (dateCols.includes(key)) {
fmt = dates.find(x => x.column === key).format;
input.placeholder = fmt;
input.title = `Date format (${fmt})`;
} else if (numeric.includes(key))
input.title = "From";
input.addEventListener("input", function (e) {
window.setTimeout(() => {
if (window.page === 0)
window.page = 1;
const max = window.page * itemsPPage.value;
const value = e.target.value;
if (stringIsNullOrWhitespace(value)) {
if (key in expressionBuilder)
expressionBuilder[key] = x => true;
} else if (numeric.includes(key))
expressionBuilder[key] = x => Number.parseFloat(value) <= Number.parseFloat(x[key]);
else if (dateCols.includes(key) && value) {
const targetVal = moment(value, fmt).toDate();
if (targetVal)
expressionBuilder[key] = x => {
if (x[key])
return x[key] >= targetVal;
return false;
};
} else if (!stringIsNullOrWhitespace(value))
expressionBuilder[key] = x => String(x[key]).toLowerCase().includes(value.toLowerCase());
const expressionBuilderValues = Object.values(expressionBuilder);
const expressionBuilderValues2 = Object.values(expressionBuilder2);
const filtered = items.filter(x =>
expressionBuilderValues.every(f => f(x)) && expressionBuilderValues2.every(f => f(x)));
setupData(max - itemsPPage.value, max, filtered);
setupPagination(window.page, filtered);
removeInvisibleColumns();
}, 1100);
});
inputContainer.appendChild(input);
inputContainer.id = `input-container${i}`;
header.appendChild(inputContainer);
}
for (let i = 0; i < colHeaderMapping.length; i++) {
const col = colHeaderMapping[i];
const key = colMapping.find(y => y.header === headers[i]).name.col;
let fmt;
if (numeric.includes(col.name) || dates.some(x => x.column === col.name)) {
const input = document.createElement("input");
input.type = "search";
if (dateCols.includes(key)) {
fmt = dates.find(x => x.column === key).format;
input.placeholder = fmt;
input.title = `Date format (${fmt})`;
} else if (numeric.includes(key))
input.title = "To";
input.addEventListener("input", function (e) {
window.setTimeout(() => {
if (window.page === 0)
window.page = 1;
const max = window.page * itemsPPage.value;
const value = e.target.value;
if (stringIsNullOrWhitespace(value)) {
if (key in expressionBuilder2)
expressionBuilder2[key] = x => true;
} else if (numeric.includes(key))
expressionBuilder2[key] = x => Number.parseFloat(value) >= Number.parseFloat(x[key]);
else if (dateCols.includes(key)) {
if (value) {
const targetVal = moment(value, fmt).toDate();
if (targetVal)
expressionBuilder2[key] = x => {
if (x[key])
return x[key] <= targetVal;
return false;
};
}
} //else return;
const expressionBuilderValues = Object.values(expressionBuilder);
const expressionBuilderValues2 = Object.values(expressionBuilder2);
const filtered = items.filter(x =>
expressionBuilderValues.every(f => f(x)) && expressionBuilderValues2.every(f => f(x)));
setupData(max - itemsPPage.value, max, filtered);
setupPagination(window.page, filtered);
removeInvisibleColumns();
}, 1100);
});
document.getElementById(`input-container${i}`).appendChild(input);
}
}
}
function setupExporting() {
const date = new Date();
const fileWithoutExt = `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`;
const setupDataset = () => window.items.map(x => {
const y = {};
for (const k in x) {
if (columns.map(x => x.col).includes(k) && !invisibleColumns.includes(k)) {
if (Array.isArray(x[k]))
y[k] = x[k].join(", ");
else y[k] = x[k];
}
}
return y;
});
const buildFile = function () {
const filterInvisible = colHeaderMapping.filter(x => !invisibleColumns.includes(x.name));
const workbook = XLSX.utils.book_new();
const worksheet = XLSX.utils.json_to_sheet(setupDataset(), {header: filterInvisible.map(x => x.name)});
XLSX.utils.book_append_sheet(workbook, worksheet, fileWithoutExt);
XLSX.utils.sheet_add_aoa(worksheet, [filterInvisible.map(x => x.text)], {origin: "A1"});
return workbook;
}
const excel = function () {
XLSX.writeFile(buildFile(), `${fileWithoutExt}.xlsx`, {compression: true});
}
const csv = function () {
XLSX.writeFile(buildFile(), `${fileWithoutExt}.csv`, {compression: true});
}
const copy = function () {
const worksheet = XLSX.utils.json_to_sheet(setupDataset());
const csv = XLSX.utils.sheet_to_csv(worksheet, {FS: '\t'});
const blob = new Blob([csv], {type: 'text/plain;charset=UTF-8'});
const reader = new FileReader();
reader.addEventListener("loadend", function (evt) {
const textArea = document.createElement("textarea");
textArea.value = evt.target.result;
textArea.select();
window.navigator.clipboard.writeText(textArea.value);
});
reader.readAsText(blob);
}
const exportContainer = document.createElement("div");
let exportBtn = document.createElement("button");
exportBtn.textContent = "To Clipboard";
exportBtn.addEventListener("click", function (evt) {
copy();
});
exportContainer.appendChild(exportBtn);
exportBtn = document.createElement("button");
exportBtn.textContent = "To Excel";
exportBtn.addEventListener("click", function (evt) {
excel();
});
exportContainer.appendChild(exportBtn);
exportBtn = document.createElement("button");
exportBtn.textContent = "To CSV";
exportBtn.addEventListener("click", function (evt) {
csv();
});
exportContainer.appendChild(exportBtn);
paginationDiv.parentElement.insertBefore(exportContainer, itemsPPage.nextSibling);
}
function removeItem(array, item) {
const index = array.indexOf(item);
// only splice array when item is found
if (index > -1)
array.splice(index, 1); // 2nd parameter means remove one item only
}
function isNumeric(str) {
if (typeof str != "string") return false; // we only process strings!
return !Number.isNaN(str) && // use type coercion to parse the _entirety_ of the string
// (`parseFloat` alone does not do this)...
!Number.isNaN(Number.parseFloat(str)); // ...and ensure strings of whitespace fail
}
function stringIsNullOrWhitespace(str) {
return str == null || str.trim() === '';
}
if (tbl) {
setupLimits();
setupData(0, itemsPPage.value, window.items);
tbl.parentNode.insertBefore(paginationDiv, tbl.nextSibling);
setupPagination(1);
setupColumnVisibility();
setupFilter();
setupExporting();
for (const t of tbl.querySelectorAll("thead tr th")) {
const delta = 6;
let startX;
let startY;
t.addEventListener('mousedown', function (event) {
startX = event.pageX;
startY = event.pageY;
});
t.addEventListener('mouseup', function (event) {
const diffX = Math.abs(event.pageX - startX);
const diffY = Math.abs(event.pageY - startY);
// Click!
if (diffX < delta && diffY < delta)
setupSorting(event, window.items);
});
}
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment