UNPKG

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
'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 ||