vanillajs-datatable
Version:
A lightweight, dependency-free, and theme-friendly DataTable alternative to jQuery DataTables built with modern JavaScript — works great with Tailwind CSS, DaisyUI, and Bootstrap.
1,572 lines (1,359 loc) • 141 kB
JavaScript
'use strict';
var jsPDF = require('jspdf');
var autoTable = require('jspdf-autotable');
/**
* DataTable Event Constants
* @namespace DataTableEvents
* @description Event names used throughout the DataTable component
*/
const DataTableEvents = {
/**
* Fired when the data table is initialized
* @event DataTable#init
*/
INIT: "init",
/**
* Fired when a column sort is applied or changed
* @event DataTable#sort
*/
SORT: "sort",
/**
* Fired when a filter input is changed
* @event DataTable#filter
*/
FILTER: "filter",
/**
* Fired when the page number changes (pagination)
* @event DataTable#pageChange
*/
PAGE_CHANGE: "pageChange",
/**
* Fired when data loading starts
* @event DataTable#loading
*/
LOADING: "loading",
/**
* Fired when data loading completes successfully
* @event DataTable#loaded
*/
LOADED: "loaded",
/**
* Fired when an error occurs during data fetching or processing
* @event DataTable#error
*/
ERROR: "error",
/**
* Fired when a search term is entered or changed
* @event DataTable#search
*/
SEARCH: "search",
/**
* Fired when the number of items per page is changed
* @event DataTable#perPageChange
*/
PER_PAGE_CHANGE: "perPageChange",
/**
* Fired when the data table is reset to initial state
* @event DataTable#reset
*/
RESET: "reset",
/**
* Fired when the table data is explicitly reloaded/refreshed
* @event DataTable#reload
*/
RELOAD: "reload",
/**
* Fired when table state is restored from saved state
* @event DataTable#stateRestored
*/
STATE_RESTORED: "stateRestored",
// Selection-related events
/**
* Fired when any selection change occurs
* @event DataTable#selectionChanged
*/
SELECTION_CHANGED: "selectionChanged",
/**
* Fired when a single row is selected
* @event DataTable#rowSelected
*/
ROW_SELECTED: "rowSelected",
/**
* Fired when a single row is deselected
* @event DataTable#rowDeselected
*/
ROW_DESELECTED: "rowDeselected",
/**
* Fired when all rows are selected
* @event DataTable#allSelected
*/
ALL_SELECTED: "allSelected",
/**
* Fired when all rows are deselected
* @event DataTable#allDeselected
*/
ALL_DESELECTED: "allDeselected",
ROW_ACTIVATE: "rowActivate",
};
// datatable/datatable-theme.js
/**
* DEFAULT_THEME
*
* This object defines the Tailwind CSS classes used for styling various parts of the DataTable.
* You can override this theme by passing a custom `theme` object when initializing the DataTable.
*
*/
const DEFAULT_THEME = {
daisyui: {
controlsContainer: "border-base-300 border-b border-dashed",
controlsWrapper:
"flex flex-wrap items-center justify-between gap-4 p-4",
controlsLeft: "flex items-center gap-2",
buttonGroup: "flex items-center gap-2",
perPageSelect: "select select-sm select-bordered",
searchWrapper: "relative w-full max-w-xs",
searchIcon: "absolute left-3 top-1/2 transform -translate-y-1/2",
searchInput: "input input-bordered w-full pl-10",
button: "btn btn-sm btn-outline",
// Table structure
table: "table w-full border border-base-200 rounded-xl overflow-hidden shadow-sm",
header: "bg-base-200 text-base-content",
headerCell: "px-4 py-3 text-sm font-semibold tracking-wide text-left",
headerSticky: "sticky top-0 z-10 bg-base-100 shadow-md",
// Group headers
groupHeaderRow:
"column-group-headers bg-base-300 text-base-content font-semibold text-center",
groupHeaderCell: "", // add any custom group header cell classes if needed
// Filter row & inputs
filterRow: "bg-base-200 column-filters",
filterInput: "input input-sm input-bordered w-full column-search",
// Body and rows
body: "bg-base-100 divide-y divide-base-200",
row: "hover:bg-base-200 transition-colors duration-200",
cell: "px-4 py-3 text-sm text-base-content",
// Highlighting search results
highlight: "bg-yellow-200 text-black font-semibold rounded-sm px-1",
// Pagination layout
paginationContainer:
"flex justify-between items-center px-4 py-2 border-t border-gray-300 bg-base-200 text-base-content rounded-b-lg",
paginationInfo: "text-sm text-gray-600",
paginationWrapper: "join gap-1 mt-2",
paginationButton: "btn btn-sm",
paginationButtonActive: "btn-primary",
paginationButtonDisabled: "opacity-50 cursor-not-allowed",
paginationEllipsis: "px-2 text-gray-500",
// Advanced Filters UI Wrapper
advancedFilterToggle:
"px-4 py-3 flex justify-between items-center cursor-pointer bg-base-200 text-sm font-medium gap-2 hover:bg-base-300 transition-colors duration-200",
advancedFilterArrow:
"transition-transform duration-300 text-base-content",
advancedFilterRow:
"grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4 py-0 px-2 bg-base-100 rounded-box transition-all duration-500 max-h-0 opacity-0 overflow-hidden",
advancedFilterDiv: "form-control",
advancedFilterLabel: "label-text text-sm mb-1",
advancedFilterInputs: "flex gap-2",
advancedFilterInput: "input input-sm input-bordered w-full",
advancedFilterButtonContainer: "flex items-center justify-start mt-5",
advancedFilterButton: "btn btn-primary btn-sm w-40 h-10",
scrollWrapperClass: "overflow-y-auto",
scrollLoaderClass: "text-center py-2 text-sm text-base-content",
editableInput: "input input-bordered input-sm w-full",
editableSelect: "select select-bordered select-sm w-full",
borderSuccess: "border-success",
borderError: "border-error",
borderLoading: "border-warning",
},
tailwind: {
controlsContainer:
"border-b border-dashed border-gray-300 bg-white dark:bg-gray-900 dark:border-gray-700",
controlsWrapper:
"flex flex-wrap items-center justify-between gap-4 p-4",
controlsLeft:
"flex flex-col sm:flex-row flex-wrap items-start sm:items-center gap-2",
buttonGroup: "flex flex-wrap items-center gap-2",
perPageSelect:
"text-sm border border-gray-300 dark:border-gray-600 dark:bg-gray-800 dark:text-white rounded-md px-3 py-1.5 focus:ring focus:ring-primary",
searchWrapper: "relative w-full max-w-xs",
searchIcon:
"absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400 dark:text-gray-500",
searchInput:
"w-full pl-10 pr-3 py-2 text-sm border border-gray-300 dark:border-gray-600 dark:bg-gray-800 dark:text-white rounded-md focus:outline-none focus:ring-2 focus:ring-primary",
button: "cursor-pointer rounded-md flex items-center gap-1 text-sm px-3 py-1.5 border border-gray-300 dark:border-gray-600 dark:bg-gray-800 dark:text-white hover:bg-gray-100 dark:hover:bg-gray-700 active:bg-gray-200 transition shadow-sm",
table: "min-w-full table-auto border border-gray-300 dark:border-gray-700 rounded-lg overflow-hidden",
header: "bg-gray-100 dark:bg-gray-800 text-gray-700 dark:text-white",
headerCell:
"px-4 py-3 text-sm font-medium text-left tracking-wide whitespace-nowrap",
headerSticky: "sticky top-0 z-10 bg-white dark:bg-gray-900 shadow",
groupHeaderRow:
"bg-gray-200 dark:bg-gray-700 text-gray-800 dark:text-gray-100 font-semibold text-center",
groupHeaderCell: "",
filterRow: "bg-gray-50 dark:bg-gray-800 column-filters",
filterInput:
"w-full pl-3 pr-3 py-2 text-sm border border-gray-300 dark:border-gray-600 dark:bg-gray-800 dark:text-white rounded-md focus:outline-none focus:ring-2 focus:ring-primary",
body: "bg-white dark:bg-gray-900 divide-y divide-gray-200 dark:divide-gray-700",
row: "hover:bg-blue-50 dark:hover:bg-gray-700 hover:shadow-sm transition-colors duration-150",
cell: "px-4 py-3 text-sm text-gray-800 dark:text-gray-100 whitespace-nowrap",
highlight:
"bg-yellow-200 dark:bg-yellow-500 text-black dark:text-gray-900 font-semibold rounded px-1",
paginationContainer:
"flex flex-col sm:flex-row justify-between sm:items-center items-start px-4 py-3 border-t border-gray-300 dark:border-gray-700 bg-gray-100 dark:bg-gray-800 text-gray-800 dark:text-gray-100 rounded-b-lg",
paginationInfo: "text-sm text-gray-600 dark:text-gray-400",
paginationWrapper: "flex gap-1 mt-2",
paginationButton:
"px-3 py-1.5 text-sm border rounded hover:bg-gray-200 dark:hover:bg-gray-700 transition cursor-pointer border-gray-300 dark:border-gray-600 text-gray-800 dark:text-white",
paginationButtonActive:
"bg-blue-600 text-white border-blue-600 cursor-pointer",
paginationButtonDisabled: "opacity-50 cursor-not-allowed",
paginationEllipsis:
"px-2 text-gray-500 dark:text-gray-400 cursor-default",
// Advanced Filters UI Wrapper
advancedFilterToggle:
"px-4 py-3 flex justify-between items-center cursor-pointer bg-gray-200 dark:bg-gray-900 text-sm font-medium gap-2 hover:bg-gray-200 dark:hover:bg-gray-700 transition-colors duration-200",
advancedFilterArrow:
"transform transition-transform duration-300 text-gray-600 dark:text-white",
advancedFilterRow:
"grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4 py-0 px-2 bg-gray-100 dark:bg-gray-900 rounded-lg transition-all duration-500 max-h-0 opacity-0 overflow-hidden",
advancedFilterDiv: "flex flex-col items-start",
advancedFilterLabel:
"block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1",
advancedFilterInputs: "flex gap-2",
advancedFilterInput:
"w-full pl-3 pr-3 py-2 text-sm border border-gray-300 dark:border-gray-600 dark:bg-gray-800 dark:text-white rounded-md focus:outline-none focus:ring-2 focus:ring-primary placeholder:font-normal",
advancedFilterButtonContainer: "flex items-center justify-start mt-5",
advancedFilterButton:
"w-40 h-10 cursor-pointer px-4 py-2 text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 rounded-md",
scrollWrapperClass: "overflow-y-auto",
scrollLoaderClass: "text-center py-2 text-sm text-gray-500",
editableInput:
"border px-2 py-1 w-full rounded focus:outline-none focus:ring-2 focus:ring-primary",
editableSelect:
"border px-2 py-1 w-full rounded focus:outline-none focus:ring-2 focus:ring-primary",
borderSuccess: "border-green-500",
borderError: "border-red-500",
borderLoading: "border-yellow-500",
},
bootstrap: {
// Container with padding but no fixed background color
controlsContainer: "py-3 border-bottom px-3",
controlsWrapper:
"d-flex flex-wrap justify-content-between align-items-center gap-3",
controlsLeft: "d-flex flex-wrap align-items-center gap-3",
buttonGroup: "btn-group flex-wrap gap-2",
perPageSelect: "form-select form-select-sm w-auto",
searchWrapper: "position-relative w-md-auto",
searchIcon:
"position-absolute top-50 start-0 translate-middle-y ps-3 text-muted",
searchInput: "form-control form-control-sm ps-5 rounded p-2",
button: "btn btn-outline-secondary btn-sm d-inline-flex align-items-center gap-1 rounded",
table: "table table-striped table-hover align-middle mb-0 border",
header: "", // Remove "table-light" to let dark mode work
headerCell: "text-nowrap",
headerSticky: "sticky-top bg-body-tertiary z-1 shadow-sm", // Use context-aware bg
groupHeaderRow: "bg-secondary text-white text-center fw-bold", // bg-secondary is better than hardcoded bg-dark
groupHeaderCell: "",
filterRow: "bg-body-secondary column-filters", // auto-adapts to dark/light
filterInput: "form-control form-control-sm column-search",
body: "",
row: "align-middle",
cell: "text-nowrap",
highlight: "bg-warning text-dark fw-semibold px-1 rounded",
paginationContainer:
"d-flex flex-column flex-md-row justify-content-between align-items-center gap-2 pt-3 mt-3 border-top px-3",
paginationInfo: "text-muted small mb-0",
paginationWrapper: "btn-group flex-wrap gap-2",
paginationButton: "btn btn-sm btn-outline-secondary rounded",
paginationButtonActive: "page-item active",
paginationButtonDisabled: "disabled",
paginationEllipsis: "px-2 text-muted",
// Advanced Filters UI Wrapper
advancedFilterToggle:
"cursor-pointer d-flex justify-content-between align-items-center px-3 py-3 cursor-pointer bg-body-secondary fw-medium hover-bg-secondary-subtle transition",
advancedFilterArrow: "transition-transform duration-300",
advancedFilterRow:
"row gy-3 gx-2 py-3 px-3 bg-body-tertiary rounded shadow-sm",
advancedFilterDiv: "col-12 col-md-6 col-lg-4 d-flex flex-column",
advancedFilterLabel: "form-label mb-1 fw-medium text-body",
advancedFilterInputs: "d-flex gap-2",
advancedFilterInput: "form-control form-control-sm",
advancedFilterButtonContainer: "px-3 mt-4 d-flex justify-content-start",
advancedFilterButton: "btn btn-primary btn-sm px-4 py-2 fw-semibold",
scrollWrapperClass: "overflow-y-auto",
scrollLoaderClass: "text-center py-2 small text-muted",
editableInput: "form-control",
editableSelect: "form-select",
borderSuccess: "border-success",
borderError: "border-danger",
borderLoading: "border-warning",
},
};
class Selectable {
/**
* @class Selectable
* @classdesc Handles row selection functionality for HTML tables
*
* @param {HTMLElement} tableElement - The table DOM element
* @param {Object} options - Configuration options
* @param {boolean} [options.selectable=false] - Enable/disable selection
* @param {string} [options.selectMode="single"] - Selection mode ("single" or "multiple")
* @param {string} [options.selectionClass="selected"] - CSS class for selected rows
* @param {string} [options.selectionBgClass="bg-red-100"] - Background class for selected rows
*/
constructor(tableElement, options = {}) {
this.table = tableElement;
this.selectable = options.selectable || false;
this.selectMode = options.selectMode || "single";
this.selectedRows = new Set();
this.selectionClass = options.selectionClass || "selected";
this.selectionBgClass = options.selectionBgClass || "bg-red-100";
this.baseTheme = options.baseTheme || "tailwind";
this.theme = DEFAULT_THEME[this.baseTheme];
if (this.selectable) {
this._initializeSelection();
}
}
// ======================
// PRIVATE METHODS
// ======================
_initializeSelection() {
this.table.addEventListener("click", (e) => {
const row = e.target.closest("tr");
if (!row || !row.dataset.id) return;
this._handleRowSelection(row);
});
this._addSelectionStyles();
}
_handleRowSelection(row) {
row.dataset.id;
const isSelected = row.classList.contains(this.selectionClass);
if (this.selectMode === "single") {
this._clearAllSelections();
}
if (isSelected) {
this._deselectRow(row);
} else {
this._selectRow(row);
}
}
_selectRow(row) {
const rowId = row.dataset.id;
const zebraClass = row.dataset.zebra;
// Remove zebra striping classes if they exist
if (zebraClass) {
row.classList.remove(zebraClass);
}
row.classList.add(
...this.selectionClass.split(" "),
this.selectionBgClass
);
if (this.baseTheme === "bootstrap") {
row.querySelectorAll("td").forEach((td) => {
td.classList.add(
this.selectionBgClass || "bg-primary",
"text-white"
);
});
}
this.selectedRows.add(rowId);
}
_deselectRow(row) {
const rowId = row.dataset.id;
const zebraClass = row.dataset.zebra;
// Always remove classes
const classesToRemove = [
...this.selectionClass.split(" "),
this.selectionBgClass,
];
row.classList.remove(...classesToRemove);
// Restore zebra striping
if (zebraClass) {
row.classList.add(zebraClass);
}
if (this.baseTheme === "bootstrap") {
row.querySelectorAll("td").forEach((td) => {
td.classList.remove(
this.selectionBgClass || "bg-primary",
"text-white"
);
});
}
// Tailwind fallback: do it again to be safe
if (this.baseTheme === "tailwind") {
classesToRemove.forEach((cls) => row.classList.remove(cls));
}
this.selectedRows.delete(rowId);
}
_clearAllSelections() {
this.table
.querySelectorAll(`tr.${this.selectionClass}`)
.forEach((row) => {
this._deselectRow(row);
});
const selectedRows = this.table.querySelectorAll(
`tr.${this.selectionClass}`
);
selectedRows.forEach((row) => {
this._deselectRow(row);
});
}
_addSelectionStyles() {
if (!document.getElementById("selectable-table-styles")) {
const style = document.createElement("style");
style.id = "selectable-table-styles";
style.textContent = `
tr.${this.selectionClass} {
cursor: pointer;
transition: background-color 0.2s ease;
}
`;
document.head.appendChild(style);
}
}
_updateRowVisualState(row, shouldBeSelected) {
if (shouldBeSelected) {
// Add selection classes
row.classList.add(this.selectionClass, this.selectionBgClass);
// Remove zebra striping if exists
if (row.dataset.zebra) {
row.classList.remove(row.dataset.zebra);
}
} else {
// Remove selection classes
row.classList.remove(this.selectionClass, this.selectionBgClass);
// Restore zebra striping if exists
if (row.dataset.zebra) {
row.classList.add(row.dataset.zebra);
}
}
}
// ======================
// PUBLIC API METHODS
// ======================
isSelected(rowId) {
return this.selectedRows.has(rowId);
}
onSelectionChange(callback) {
this.table.addEventListener("click", (e) => {
const row = e.target.closest("tr");
if (row && row.dataset.id) {
// Dispatch general selection change event
callback(this.getSelectedIds());
}
});
}
getSelectedIds() {
return Array.from(this.selectedRows);
}
clearSelection() {
this.getSelectedIds();
this._clearAllSelections();
}
selectAll() {
if (this.selectMode === "single") return;
const allRows = this.table.querySelectorAll("tr[data-id]");
allRows.forEach((row) => {
this._selectRow(row);
});
}
toggleRowSelection(rowId, force) {
const row = this.table.querySelector(`tr[data-id="${rowId}"]`);
if (!row) return false;
const isSelected = this.selectedRows.has(rowId);
const shouldSelect = force !== undefined ? force : !isSelected;
// Skip if no change needed
if (shouldSelect === isSelected) return isSelected;
if (shouldSelect) {
if (this.selectMode === "single") {
this._clearAllSelections();
}
this._selectRow(row);
} else {
this._deselectRow(row);
}
return shouldSelect;
}
// ========= 1. Query helpers =========
getSelectedRows() {
return Array.from(
this.table.querySelectorAll(`tr.${this.selectionClass}`)
);
}
getSelectedData() {
return this.getSelectedRows().map((tr) =>
JSON.parse(tr.dataset.row || "{}")
);
}
getSelectedCount() {
return this.selectedRows.size;
}
// ========= 2. Granular selection =========
setSelection(ids = []) {
this.clearSelection();
ids.forEach((id) => this.toggleRowSelection(id, true));
return this.selectedRows.size;
}
invertSelection() {
if (this.selectMode === "single") {
const firstRow = this.table.querySelector("tr[data-id]");
if (!firstRow) return;
this.toggleRowSelection(firstRow.dataset.id); // let toggle do the work
return;
}
// build a *single* NodeList once
const rows = Array.from(
this.table.querySelectorAll("tbody tr[data-id]")
);
rows.forEach((row) => {
const id = row.dataset.id;
const shouldSelect = !this.isSelected(id);
// 1. update Set
shouldSelect
? this.selectedRows.add(id)
: this.selectedRows.delete(id);
// 2. update visuals & events
shouldSelect ? this._selectRow(row) : this._deselectRow(row);
});
}
selectRange(fromId, toId) {
if (this.selectMode === "single") return;
const rows = Array.from(this.table.querySelectorAll("tr[data-id]"));
const fromIx = rows.findIndex((r) => r.dataset.id === fromId);
const toIx = rows.findIndex((r) => r.dataset.id === toId);
if (fromIx === -1 || toIx === -1) return;
const [start, end] = fromIx < toIx ? [fromIx, toIx] : [toIx, fromIx];
for (let i = start; i <= end; i++) {
this.toggleRowSelection(rows[i].dataset.id, true);
}
}
// ========= 3. Programmatic control =========
setSelectable(flag = true) {
this.selectable = Boolean(flag);
}
setSelectMode(mode) {
if (!["single", "multiple"].includes(mode)) return;
this.selectMode = mode;
if (mode === "single" && this.selectedRows.size > 1) {
const keep = this.getSelectedIds()[0];
this.clearSelection();
this.toggleRowSelection(keep, true);
}
}
destroy() {
this.clearSelection();
// remove click listener that was added in _initializeSelection
this.table.removeEventListener("click", this._boundClickHandler);
// optional: remove injected <style> if you kept a reference
}
}
// Navigation Keys
// | Key | Action |
// | ------------- | ---------------------------------- |
// | `ArrowUp` | Select previous row |
// | `ArrowDown` | Select next row |
// | `ArrowLeft` | Go to previous page (calls method) |
// | `ArrowRight` | Go to next page (calls method) |
// | `Home` | Go to first row |
// | `Ctrl + Home` | Go to first page (calls method) |
// | `End` | Go to last row |
// | `Ctrl + End` | Go to last page (calls method) |
// Action Keys (with modifier)
// | Shortcut | Action |
// | ---------- | ------------------ |
// | `Ctrl + P` | Print |
// | `Ctrl + S` | Focus search input |
// | `Ctrl + E` | Export to Excel |
// | `Ctrl + C` | Export to CSV |
// | `Ctrl + D` | Export to PDF |
// | `Ctrl + R` | Reload data |
// | `Ctrl + F` | Focus search input |
// | `Ctrl + Z` | Reset table |
// Action Keys (no modifier)
// | Key | Action |
// | -------- | ------------------------------------- |
// | `/` | Focus search input |
// | `a` | Select all rows (if in multiple mode) |
// | `Space` | Toggle selection of current row |
// | `Enter` | Trigger row activation (open row) |
// | `Escape` | Clear all selected rows |
class KeyboardNavigation {
/**
* @class KeyboardNavigation
* @classdesc Handles keyboard navigation for DataTable
*
* @param {HTMLElement} tableElement - The table DOM element
* @param {Object} options - Configuration options
* @param {Selectable} options.selectable - The Selectable instance
* @param {Function} options.getData - Function to get current table data
* @param {boolean} [options.enabled=true] - Enable/disable keyboard nav
*/
constructor(tableElement, { selectable, getData, enabled = true, main }) {
this.table = tableElement;
this.selectable = selectable;
this.getData = getData;
this.enabled = enabled;
this.lastSelectedRow = null;
this._boundKeyHandler = this.handleKeyDown.bind(this);
this.main = main;
if (this.enabled) {
this.init();
}
}
/**
* Initialize keyboard navigation
*/
init() {
document.addEventListener("keydown", this._boundKeyHandler);
return this;
}
/**
* Destroy keyboard navigation
*/
destroy() {
document.removeEventListener("keydown", this._boundKeyHandler);
this.lastSelectedRow = null;
}
/**
* Handle keyboard events
* @param {KeyboardEvent} e
*/
handleKeyDown(e) {
if (!this.enabled) return;
if (this._shouldIgnoreKeyEvent(e)) return;
// Navigation keys
switch (e.key) {
case "ArrowUp":
e.preventDefault();
this.navigateRow(-1);
break;
case "ArrowDown":
e.preventDefault();
this.navigateRow(1);
break;
case "ArrowLeft":
e.preventDefault();
this._navigatePage(-1);
break;
case "ArrowRight":
e.preventDefault();
this._navigatePage(1);
break;
case "Home":
e.preventDefault();
if (e.ctrlKey) {
this._goToFirstPage();
} else {
this._goToFirstRow();
}
break;
case "Enter":
e.preventDefault();
this.openSelectedRow();
break;
case "Escape":
e.preventDefault();
this.selectable.clearSelection();
break;
}
// Action keys (with modifiers)
if (e.ctrlKey || e.metaKey) {
switch (e.key.toLowerCase()) {
case "p":
e.preventDefault();
this._triggerPrint();
break;
case "s":
e.preventDefault();
this._triggerSearch();
break;
case "e":
e.preventDefault();
this._triggerExport("excel");
break;
case "c":
e.preventDefault();
this._triggerExport("csv");
break;
case "d":
e.preventDefault();
this._triggerExport("pdf");
break;
case "r":
e.preventDefault();
this.reloadData();
break;
case "f":
e.preventDefault();
this._focusSearchInput();
break;
case "z":
e.preventDefault();
this._triggerReset();
break;
}
}
// Single key shortcuts (without modifiers)
if (!e.ctrlKey && !e.metaKey && !e.altKey) {
switch (e.key) {
case "/":
e.preventDefault();
this._focusSearchInput();
break;
case "a":
if (this.selectable.selectMode === "multiple") {
e.preventDefault();
this.selectable.selectAll();
}
break;
case " ":
e.preventDefault();
this._toggleRowSelection();
break;
}
}
}
/**
* Reload table data
*/
/**
* Navigate between rows
* @param {number} direction - 1 for down, -1 for up
*/
navigateRow(direction) {
const rows = this._getVisibleRows();
if (rows.length === 0) return;
const currentIndex = this._getCurrentRowIndex(rows);
const newIndex = Math.max(
0,
Math.min(currentIndex + direction, rows.length - 1)
);
if (currentIndex !== newIndex) {
if (
this.selectable.selectMode === "single" &&
this.lastSelectedRow
) {
this.selectable.toggleRowSelection(
this.lastSelectedRow.dataset.id,
false
);
}
this._selectRow(rows[newIndex]);
}
}
/**
* Open the currently selected row
*/
openSelectedRow() {
const selectedIds = this.selectable.getSelectedIds();
if (selectedIds.length === 0) return;
this.table.dispatchEvent(
new CustomEvent(`datatable:${DataTableEvents.ROW_ACTIVATE}`, {
detail: {
rowId: selectedIds[0],
rowData: this.getData().find(
(item) => item.id === selectedIds[0]
),
timestamp: new Date().toISOString(),
},
bubbles: true,
})
);
}
// ======================
// PRIVATE METHODS
// ======================
_shouldIgnoreKeyEvent(e) {
return (
["INPUT", "TEXTAREA", "SELECT"].includes(
document.activeElement.tagName
) ||
(e.ctrlKey && e.key.toLowerCase() === "c") || // Allow Ctrl+C for copy
e.altKey
);
}
_getVisibleRows() {
return Array.from(this.table.querySelectorAll("tbody tr[data-id]"));
}
_getCurrentRowIndex(rows) {
if (this.lastSelectedRow) {
return rows.indexOf(this.lastSelectedRow);
}
const selectedIds = this.selectable.getSelectedIds();
if (selectedIds.length > 0) {
this.lastSelectedRow = this.table.querySelector(
`tr[data-id="${selectedIds[0]}"]`
);
return rows.indexOf(this.lastSelectedRow);
}
return -1;
}
_selectRow(row) {
this.selectable.toggleRowSelection(row.dataset.id, true);
this.lastSelectedRow = row;
this._scrollRowIntoView(row);
this.table.dispatchEvent(
new CustomEvent(`datatable:${DataTableEvents.ROW_ACTIVATE}`, {
detail: {
rowId: row.dataset.id,
rowData: this.getData().find(
(item) => item.id === row.dataset.id
),
timestamp: new Date().toISOString(),
},
bubbles: true,
})
);
}
_scrollRowIntoView(row) {
row.scrollIntoView({
behavior: "smooth",
block: "nearest",
inline: "nearest",
});
}
_focusSearchInput() {
const searchInput =
document.getElementById(`${this.table.id}-search-input`) ||
this.table.querySelector(".datatable-search-input") ||
document.querySelector("input[data-datatable-search]");
if (searchInput) {
searchInput.focus();
searchInput.select();
return true;
}
console.warn("Search input not found for table:", this.table.id);
return false;
}
_toggleRowSelection() {
const rows = this._getVisibleRows();
if (rows.length === 0) return;
const currentIndex = this._getCurrentRowIndex(rows);
if (currentIndex >= 0) {
const row = rows[currentIndex];
const isSelected = this.selectable
.getSelectedIds()
.includes(row.dataset.id);
this.selectable.toggleRowSelection(row.dataset.id, !isSelected);
}
}
_goToFirstRow() {
const rows = this._getVisibleRows();
if (rows.length > 0) {
this._selectRow(rows[0]);
}
}
_goToLastRow() {
const rows = this._getVisibleRows();
if (rows.length > 0) {
this._selectRow(rows[rows.length - 1]);
}
}
// Ctrl + Home
_goToFirstPage() {
if (this.main?.goToFirstPage) {
this.main.goToFirstPage();
}
}
// Ctrl + F , Ctrl + S , /
_triggerSearch() {
this._focusSearchInput();
}
_triggerExport(format) {
if (!this.main || !this.main.buttonConfig) return;
const config = this.main.buttonConfig;
if (format === "csv" && config.downloadCsv?.enabled !== false) {
// Ctrl + C
this.main.downloadCSV();
} else if (format === "pdf" && config.downloadPdf?.enabled !== false) {
// Ctrl + D
this.main.downloadPdf();
} else if (
// Ctrl + E
format === "excel" &&
config.exportExcel?.enabled !== false
) {
this.main.exportToExcel();
} else {
console.warn(
`Export format "${format}" is disabled or unsupported.`
);
}
}
// Ctrl + P
_triggerPrint() {
if (
this.main.printTable &&
this.main.buttonConfig?.print?.enabled !== false
) {
this.main.printTable();
}
}
// Ctrl + Z
_triggerReset() {
if (
this.main.resetTable &&
this.main.buttonConfig?.reset?.enabled !== false
) {
this.main.resetTable();
}
}
// Ctrl + R
reloadData() {
this.main.fetchData();
}
}
// ------------------------------------------
// DataTable CRUD – dataMethods.js
// ------------------------------------------
/* ---------- 1. Read helpers ---------- */
/**
* Returns a shallow copy of the current data array.
* @returns {Array<Object>} Copy of the table data
*/
function getData() {
return [...this.data]; // shallow copy
}
/**
* Returns the row object matching the given id.
* @param {string|number} rowId - Unique identifier of the row
* @returns {Object|null} The row object or null if not found
*/
function getRowData(rowId) {
return this.data.find((row) => row.id === rowId) || null;
}
/**
* Returns the index of the row with the given id.
* @param {string|number} rowId - Unique identifier of the row
* @returns {number} Row index or -1 if not found
*/
function getRowIndex(rowId) {
return this.data.findIndex((row) => row.id === rowId);
}
/**
* Returns every row whose exact field value matches the supplied value.
* @param {string} field - Property name to inspect
* @param {*} value - Exact value to match
* @returns {Array<Object>} Array of matching rows
*/
function getRowsBy(field, value) {
return this.data.filter((r) => r[field] === value);
}
/**
* Case-insensitive, partial match on a given field.
* @param {string} field - Property name to search
* @param {string} value - Sub-string to look for (case-insensitive)
* @returns {Array<Object>} Array of matching rows
*/
function findRowsByFieldContains(field, value) {
const v = String(value).toLowerCase();
return this.data.filter((r) =>
String(r[field] ?? "")
.toLowerCase()
.includes(v)
);
}
/* ---------- 2. Create ---------- */
/**
* Adds a single row to the table.
* @param {Object} data - Row object (must contain a unique `id`)
* @param {boolean} [silent=false] - Skip re-render and event dispatch
* @param {boolean} [prepend=false] - Insert at the start of the array
* @returns {Object|false} The added row object or `false` on failure
*/
function addRow(data, silent = false, prepend = false) {
if (!data.id) {
console.warn("Each row must have a unique `id`.");
return false;
}
if (this.getRowData(data.id)) {
console.warn(`Row with id ${data.id} already exists`);
return false;
}
prepend ? this.data.unshift(data) : this.data.push(data);
if (!silent) {
this._renderTable();
}
return true;
}
/**
* Adds multiple rows in one shot.
* @param {Array<Object>} rows - Array of row objects
* @param {boolean} [silent=false] - Skip re-render and event dispatch
* @returns {Array<Object>} Array of successfully added rows
*/
function addRows(rows, silent = false) {
if (!Array.isArray(rows)) throw new TypeError("addRows expects an array");
const added = rows.map((r) => this.addRow(r, true, false)); // silent single adds
if (!silent) {
this._renderTable();
}
return added;
}
/* ---------- 3. Update ---------- */
/**
* Updates a single row, merging the supplied fields.
* @param {string|number} rowId - Id of the row to update
* @param {Object} newData - Fields to merge into the existing row
* @param {boolean} [silent=false] - Skip re-render and event dispatch
* @returns {Object|false} Updated row object or `false` if not found
*/
function updateRow(rowId, newData) {
const index = this.data.findIndex((row) => row.id === rowId);
if (index === -1) return false;
this.data[index] = { ...this.data[index], ...newData };
this._renderTable();
return true;
}
/**
* Batch-updates multiple rows.
* @param {Array<{id:string|number}>} updates - Array of `{id, ...newFields}` objects
* @param {boolean} [silent=false] - Skip re-render and event dispatch
* @returns {Array<Object>} Array of updated rows
*/
function updateRows(updates, silent = false) {
// updates = [{id, ...newFields}, ...]
const updated = [];
updates.forEach(({ id, ...fields }) => {
const row = this.updateRow(id, fields, true);
if (row) updated.push(row);
});
if (!silent && updated.length) {
this._renderTable();
}
return updated;
}
/* ---------- 4. Delete ---------- */
/**
* Removes a single row by id.
* @param {string|number} rowId - Id of the row to delete
* @param {boolean} [silent=false] - Skip re-render and event dispatch
* @returns {Object|false} Deleted row object or `false` if not found
*/
function deleteRow(rowId) {
const index = this.data.findIndex((row) => row.id === rowId);
if (index === -1) return false;
this.data.splice(index, 1);
this._renderTable();
return true;
}
/**
* Batch-removes multiple rows by id.
* @param {(string|number)[]} ids - Array of ids to delete
* @param {boolean} [silent=false] - Skip re-render and event dispatch
* @returns {Array<Object>} Array of deleted rows
*/
function deleteRows(ids, silent = false) {
if (!Array.isArray(ids)) ids = [ids];
const removed = [];
ids.forEach((id) => {
const row = this.deleteRow(id, true);
if (row) removed.push(row);
});
if (!silent && removed.length) {
this._renderTable();
}
return removed;
}
/* ---------- 5. Redraw helpers ---------- */
/**
* Force a full re-render of the table UI.
* Useful after manual data manipulation.
* @returns {void}
*/
function redraw() {
if (typeof this._renderTable !== "function")
throw new Error("_renderTable method not found");
this._renderTable();
}
// Reusable download function
function downloadJSON(data, filename = "data.json") {
const jsonStr = JSON.stringify(data, null, 2);
const blob = new Blob([jsonStr], { type: "application/json" });
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = filename;
link.click();
// Delay revoking the URL to ensure the download completes
setTimeout(() => URL.revokeObjectURL(url), 1000);
}
// For exporting all rows
function exportJSON(data, filename = "table-data.json") {
downloadJSON(data, filename);
}
function setSort(column, direction = "asc") {
// 1. Validate direction
if (!["asc", "desc"].includes(direction)) {
console.warn(
`Invalid sort direction "${direction}" - must be "asc" or "desc"`
);
return;
}
// 2. Validate column
const columnExists = this.columns.some(
(col) => col.data === column || col.name === column
);
if (!columnExists) {
console.warn(
`Invalid column "${column}" - not found in column definition`
);
return;
}
// 3. Apply and fetch
this.sort = column;
this.order = direction;
this.fetchData();
}
function clearSort() {
this.sort = "";
this.order = "";
// Fetch data without sorting
this.fetchData();
}
function copyToClipboard(format = "csv") {
const visibleData = this.data; // current page data
if (!visibleData || visibleData.length === 0) {
alert("No data to copy.");
return;
}
const headers = this.columns
.filter((col) => col.visible !== false && col.name !== "actions") // exclude non-visible or action columns
.map((col) => col.label || col.name);
const rows = visibleData.map((row) => {
return this.columns
.filter((col) => col.visible !== false && col.name !== "actions")
.map((col) => row[col.name] ?? "")
.join(format === "csv" ? "," : "\t");
});
const dataString = [
headers.join(format === "csv" ? "," : "\t"),
...rows,
].join("\n");
// Copy to clipboard
navigator.clipboard
.writeText(dataString)
.then(() => {
console.log("Table data copied to clipboard");
})
.catch((err) => {
console.error("Failed to copy data:", err);
});
}
function goToPage(pageNumber) {
const page = parseInt(pageNumber, 10);
if (
isNaN(page) ||
page < 1 ||
(this.totalPages && page > this.totalPages)
) {
console.warn(`Invalid page number: ${pageNumber}`);
return;
}
this.currentPage = page;
this.fetchData(); // Re-fetch data for the new page
}
function setPageSize(size) {
const perPage = parseInt(size, 10);
if (isNaN(perPage) || perPage <= 0) {
console.warn(`Invalid page size: ${size}`);
return;
}
this.rowsPerPage = perPage;
this.currentPage = 1; // Reset to first page when page size changes
this.fetchData();
}
function getCurrentPage() {
return this.currentPage;
}
// ---------- Navigation ----------
function nextPage() {
return this.goToPage(this.currentPage + 1);
}
function prevPage() {
return this.goToPage(this.currentPage - 1);
}
function firstPage() {
return this.goToPage(1);
}
function lastPage() {
if (!this.totalPages || this.totalPages < 1) {
console.warn("Cannot go to last page: totalPages is not defined");
return;
}
return this.goToPage(this.totalPages);
}
class DataTable {
constructor({
data,
tableId,
url,
perPage = 10,
perPageOptions = [10, 25, 50],
defaultSort = "id",
defaultOrder = "asc", // Order direction must be "asc" or "desc".
columns = [], // Add default empty array here
dataSrc = null,
saveState = false,
keyboardNav = false,
// Element IDs
searchInputId = null,
prevBtnId = null,
nextBtnId = null,
pageInfoId = null,
infoTextId = null,
paginationWrapperId = null,
perPageSelectId = null,
// Button IDs and visibility flags
resetBtnId = null,
reloadBtnId = null,
exportBtnId = null,
downloadCsvBtnId = null,
printBtnId = null,
pdfBtnId = null,
// Features
paginationType = "detailed",
sortable = true,
sortableColumns = [],
searchDelay = 300, // new
reset = true,
reload = true,
perPageSelector = true,
searchable = true,
pagination = true,
filterableColumns = null, // Array of column names to filter (for default inputs)
columnGroups = [], // Add default empty array here
stickyHeader = false,
columnFiltering = false,
saveStateDuration = 60 * 60 * 1000, // 1 hour
theme = {}, // default to empty object
baseTheme = "tailwind",
rangeFilterFields = {},
loading = {
show: false,
elementId: null,
delay: 1000,
},
selection = {
enabled: false,
mode: "single", // 'single'|'multiple'
rowClass: "row-selected",
backgroundClass: "bg-blue-100",
},
infiniteScroll = {
enabled: false,
scrollOffset: 10,
hidePaginationOnScroll: true,
maxScrollPages: 1000,
scrollWrapperHeight: "80vh",
},
exportable = {
enabled: true,
buttons: {
print: true,
excel: true,
csv: true,
pdf: true,
},
title: {
print: "Printable Report",
pdf: "PDF Export",
excel: "Excel Export",
csv: "CSV Export",
},
chunkSize: {
print: 50,
pdf: 50,
excel: 50,
csv: 50,
},
pdfOptions: {
orientation: "portrait",
unit: "mm",
format: "a4",
theme: "grid",
watermark: {
text: null,
opacity: 0.1,
angle: 45,
},
},
fileName: {
print: "print_report",
pdf: "pdf_export",
excel: "excel_export",
csv: "csv_export",
},
footer: true,
},
}) {
const infiniteScrollConfig = {
enabled: infiniteScroll?.enabled !== false,
scrollOffset: infiniteScroll?.scrollOffset,
hidePaginationOnScroll: infiniteScroll?.hidePaginationOnScroll,
maxScrollPages: infiniteScroll?.maxScrollPages,
scrollWrapperHeight: infiniteScroll?.scrollWrapperHeight,
};
this.infiniteScroll = infiniteScrollConfig.enabled;
this.scrollOffset = infiniteScrollConfig.scrollOffset;
this.hidePaginationOnScroll = infiniteScrollConfig.hidePaginationOnScroll;
this.maxScrollPages = infiniteScrollConfig.maxScrollPages;
this.scrollWrapperHeight = infiniteScrollConfig.scrollWrapperHeight;
this.rangeFilterFields = rangeFilterFields;
const selectedTheme = DEFAULT_THEME[baseTheme] || DEFAULT_THEME.daisyui;
this.theme = {
...selectedTheme,
...theme, // override specific classes
framework: baseTheme.includes("bootstrap")
? "bootstrap"
: baseTheme.includes("daisyui")
? "daisyui"
: "tailwind",
};
this.data = [];
this.tableId = tableId;
this.table = document.getElementById(tableId);
this.url = url;
this.rowsPerPage = perPage;
this.perPageOptions = perPageOptions; // Store the custom per-page options
this.sort = defaultSort;
this.order = defaultOrder;
this.search = "";
// this.chunkSize = chunkSize;
this.currentPage = 1;
this.dataSrc = dataSrc || "data"; // Default to 'data' if not provided
this.enableSaveState = saveState;
this.saveStateDuration = saveStateDuration;
this.updatePagination = this.updatePagination.bind(this);
this.paginationType = paginationType;
this.sortable = sortable;
this.pagination = pagination;
// this.sortableColumns = sortableColumns;
this.sortableColumns = Array.isArray(sortableColumns)
? sortableColumns
: [];
this.searchDelay = searchDelay;
this.columnFilters = {};
this.columns = columns;
this.searchDebounceTimer = null;
const loadingConfig = {
show: loading?.show !== false,
elementId: loading?.elementId,
delay: loading?.delay,
};
this.enableLoadingSpinner = loadingConfig.show;
this.LoadingSpinnerContainer =
loadingConfig.elementId || `${tableId}-loading-spinner`;
this.loadingDelay = loadingConfig.delay;
this.columnGroups = columnGroups || [];
this.stickyHeader = stickyHeader;
this.columnFiltering = columnFiltering;
// Button configuration
this.exportable = {
enabled: exportable.enabled !== false, // default true unless explicitly false
buttons: {
print: exportable.buttons?.print !== false,
excel: exportable.buttons?.excel !== false,
csv: exportable.buttons?.csv !== false,
pdf: exportable.buttons?.pdf === true, // default false unless explicitly true
...exportable.buttons,
},
title: {
print: exportable.title?.print ||