UNPKG

vanillajs-excelike-table

Version:

A user-friendly pure JavaScript table library with Excel-like features, preset configurations, and intuitive column helpers. Vanilla JS implementation - no frameworks required!

1,564 lines (1,351 loc) 140 kB
/** * ExceLike Table - Pure JavaScript implementation * Excel-like table with filtering, sorting, pagination, and column pinning */ // Predefined table presets for easy setup const TABLE_PRESETS = { simple: { features: ['sorting'], bordered: true, size: 'middle', pagination: { pageSize: 10 } }, standard: { features: ['sorting', 'filtering', 'pagination'], bordered: true, size: 'middle', pagination: { pageSize: 10, showSizeChanger: true } }, advanced: { features: ['sorting', 'filtering', 'pagination', 'columnSettings', 'persistSettings'], bordered: true, size: 'middle', pagination: { pageSize: 10, showSizeChanger: true } }, excel: { features: ['sorting', 'filtering', 'pagination', 'columnSettings', 'persistSettings', 'columnResizing', 'columnPinning'], bordered: true, size: 'middle', pagination: { pageSize: 10, showSizeChanger: true } } }; // Configuration constants const TABLE_CONFIG = { FONT_SIZES: { 'smallest': '10px', 'small': '12px', 'medium': '14px', 'large': '18px', 'largest': '22px' }, CELL_PADDING: { 'wide': { vertical: '12px', horizontal: '8px' }, 'standard': { vertical: '8px', horizontal: '6px' }, 'narrow': { vertical: '2px', horizontal: '2px' } }, SIZES: { MIN_COLUMN_WIDTH: 50, AUTO_RESIZE_MIN: 80, AUTO_RESIZE_MAX: 400, DOUBLE_CLICK_THRESHOLD: 300, TOOLTIP_OFFSET: { x: 15, y: 40 } }, Z_INDEXES: { TOOLTIP: 10000, FILTER_DROPDOWN: 9999, PINNED_HEADER: 101, PINNED_CELL: 100 }, TIMING: { TOOLTIP_FADE: 200, DEBOUNCE_RESIZE: 100 } }; // Column definition helpers for easier setup const ColumnHelpers = { /** * Create a simple text column */ text(key, title, options = {}) { return { key, title, dataIndex: key, width: options.width || 150, sortable: options.sortable !== false, filterable: options.filterable !== false, ...options }; }, /** * Create a number column with automatic formatting */ number(key, title, options = {}) { return { key, title, dataIndex: key, width: options.width || 120, sortable: options.sortable !== false, filterable: options.filterable !== false, render: options.render || ((value) => { if (typeof value === 'number') { return options.currency ? `${options.currency}${value.toLocaleString()}` : value.toLocaleString(); } return value; }), ...options }; }, /** * Create a date column with automatic formatting */ date(key, title, options = {}) { return { key, title, dataIndex: key, type: 'date', width: options.width || 130, sortable: options.sortable !== false, filterable: options.filterable !== false, filterType: 'date-hierarchy', render: options.render || ((value) => { if (!value) return ''; return new Date(value).toLocaleDateString(); }), ...options }; }, /** * Create a status column with color coding */ status(key, title, statusColors = {}, options = {}) { return { key, title, dataIndex: key, width: options.width || 100, sortable: options.sortable !== false, filterable: options.filterable !== false, render: (value) => { const color = statusColors[value] || '#000'; return `<span style="color: ${color}; font-weight: 500;">${value}</span>`; }, ...options }; }, /** * Create an action column with buttons */ actions(title, actions, options = {}) { return { key: 'actions', title: title || 'Actions', dataIndex: 'actions', width: options.width || 120, sortable: false, filterable: false, render: (value, record) => { return actions.map(action => `<button class="action-btn" data-action="${action.key}" data-id="${record.id || record.key}">${action.label}</button>` ).join(' '); }, ...options }; } }; // Utility functions const TableUtils = { /** * Get decimal places from a numeric value */ getDecimalPlaces(values) { let maxDecimals = 0; values.forEach(value => { if (typeof value === 'number' && !Number.isInteger(value)) { const decimals = value.toString().split('.')[1]?.length || 0; maxDecimals = Math.max(maxDecimals, decimals); } }); return maxDecimals; }, /** * Format number value based on decimal places */ formatNumberValue(value, decimalPlaces) { if (typeof value !== 'number') return value; return decimalPlaces > 0 ? value.toFixed(decimalPlaces) : Math.round(value).toString(); }, /** * Check if column contains numeric data */ isNumericColumn(column, data) { return data.slice(0, 10).every(row => { const value = row[column.dataIndex]; return value === null || value === undefined || value === '' || !isNaN(Number(value)); }); }, /** * Check if value is a valid date */ isDateValue(value) { if (!value) return false; const date = new Date(value); return date instanceof Date && !isNaN(date) && value.toString().match(/^\d{4}-\d{2}-\d{2}/); }, /** * Get checked filter values from dropdown */ getCheckedFilterValues(dropdown, excludeAll = true) { return Array.from(dropdown.querySelectorAll('.filter-checkbox:checked')) .map(cb => cb.dataset.value) .filter(val => excludeAll ? val !== '__all__' : true); }, /** * Update select all checkbox state */ updateSelectAllState(selectAllCheckbox, childCheckboxes) { const checkedCount = Array.from(childCheckboxes).filter(cb => cb.checked).length; const totalCount = childCheckboxes.length; if (checkedCount === 0) { selectAllCheckbox.checked = false; selectAllCheckbox.indeterminate = false; } else if (checkedCount === totalCount) { selectAllCheckbox.checked = true; selectAllCheckbox.indeterminate = false; } else { selectAllCheckbox.checked = false; selectAllCheckbox.indeterminate = true; } }, /** * Convert px to cm */ pxToCm(px) { const dpi = window.devicePixelRatio * 96; return (px * 2.54 / dpi).toFixed(2); }, /** * Prevent element overflow from viewport */ constrainToViewport(element, x, y, offsetX = 0, offsetY = 0) { const rect = element.getBoundingClientRect(); const maxX = window.innerWidth - rect.width - 10; const maxY = window.innerHeight - rect.height - 10; return { x: Math.max(10, Math.min(x + offsetX, maxX)), y: Math.max(10, Math.min(y + offsetY, maxY)) }; } }; // Storage interface for flexible data persistence class TableStorageAdapter { /** * Default LocalStorage implementation */ constructor() { this.storage = localStorage; } /** * Save table settings * @param {string} key - Storage key * @param {Object} settings - Settings object to save * @returns {Promise<boolean>} Success status */ async save(key, settings) { try { this.storage.setItem(key, JSON.stringify(settings)); return true; } catch (error) { console.warn('Failed to save table settings:', error); return false; } } /** * Load table settings * @param {string} key - Storage key * @returns {Promise<Object|null>} Loaded settings or null */ async load(key) { try { const data = this.storage.getItem(key); return data ? JSON.parse(data) : null; } catch (error) { console.warn('Failed to load table settings:', error); return null; } } /** * Remove table settings * @param {string} key - Storage key * @returns {Promise<boolean>} Success status */ async remove(key) { try { this.storage.removeItem(key); return true; } catch (error) { console.warn('Failed to remove table settings:', error); return false; } } /** * Check if storage is available * @returns {boolean} Availability status */ isAvailable() { try { const testKey = '__table_storage_test__'; this.storage.setItem(testKey, 'test'); this.storage.removeItem(testKey); return true; } catch { return false; } } } // Settings manager for table persistence class TableSettingsManager { /** * @param {string} tableId - Unique table identifier * @param {TableStorageAdapter} storageAdapter - Storage implementation */ constructor(tableId, storageAdapter = null) { this.tableId = tableId; this.storageAdapter = storageAdapter || new TableStorageAdapter(); this.storageKey = `excelike_table_${tableId}`; } /** * Get current table settings * @param {ExceLikeTable} table - Table instance * @returns {Object} Current settings */ extractSettings(table) { // Validate and sanitize column widths const sanitizedColumnWidths = {}; for (const [column, width] of Object.entries(table.state.columnWidths || {})) { // Limit column width to reasonable range (50px - 2000px) const sanitizedWidth = Math.max(50, Math.min(2000, width)); sanitizedColumnWidths[column] = sanitizedWidth; } return { // Column settings columnWidths: sanitizedColumnWidths, visibleColumns: { ...table.state.visibleColumns }, pinnedColumns: { ...table.state.pinnedColumns }, // Display settings fontSize: table.state.fontSize, cellPadding: table.state.cellPadding, pageSize: table.state.pageSize, // Metadata version: '1.0.0', timestamp: Date.now() }; } /** * Apply settings to table * @param {ExceLikeTable} table - Table instance * @param {Object} settings - Settings to apply */ applySettings(table, settings) { if (!settings || !this.isValidSettings(settings)) { return false; } try { // Apply column settings if (settings.columnWidths) { Object.assign(table.state.columnWidths, settings.columnWidths); } if (settings.visibleColumns) { Object.assign(table.state.visibleColumns, settings.visibleColumns); } if (settings.pinnedColumns) { Object.assign(table.state.pinnedColumns, settings.pinnedColumns); } // Filters and sort settings are not persisted - they reset on reload // Apply display settings if (settings.fontSize) { table.state.fontSize = settings.fontSize; } if (settings.cellPadding) { table.state.cellPadding = settings.cellPadding; } if (settings.pageSize) { table.state.pageSize = settings.pageSize; } return true; } catch (error) { console.warn('Failed to apply table settings:', error); return false; } } /** * Validate settings object * @param {Object} settings - Settings to validate * @returns {boolean} Validation result */ isValidSettings(settings) { if (!settings || typeof settings !== 'object' || !settings.version || !settings.timestamp) { return false; } // Validate column widths if (settings.columnWidths) { for (const [column, width] of Object.entries(settings.columnWidths)) { if (typeof width !== 'number' || width < 50 || width > 2000) { console.warn(`Invalid column width for ${column}: ${width}. Must be between 50-2000px`); return false; } } } return true; } /** * Save current table settings * @param {ExceLikeTable} table - Table instance * @returns {Promise<boolean>} Save success status */ async saveSettings(table) { if (!this.storageAdapter.isAvailable()) { return false; } const settings = this.extractSettings(table); return await this.storageAdapter.save(this.storageKey, settings); } /** * Load and apply saved settings * @param {ExceLikeTable} table - Table instance * @returns {Promise<boolean>} Load success status */ async loadSettings(table) { if (!this.storageAdapter.isAvailable()) { return false; } const settings = await this.storageAdapter.load(this.storageKey); if (settings) { // Validate settings before applying if (!this.isValidSettings(settings)) { console.warn('TableSettingsManager: Invalid settings detected, clearing storage'); await this.storageAdapter.remove(this.storageKey); return false; } return this.applySettings(table, settings); } return false; } /** * Clear saved settings * @returns {Promise<boolean>} Clear success status */ async clearSettings() { return await this.storageAdapter.remove(this.storageKey); } } /** * ExceLike Table Component * @class ExceLikeTable * @description A comprehensive table component with Excel-like features including * filtering, sorting, pagination, column pinning, and more. */ class ExceLikeTable { /** * Create an ExceLike Table instance * @param {string|HTMLElement} container - Container element or selector * @param {Object} options - Configuration options * @param {Array} options.data - Table data array * @param {Array} options.columns - Column definitions (use ColumnHelpers for easier setup) * @param {string} options.preset - Predefined configuration: 'simple', 'standard', 'advanced', 'excel' * @param {Array} options.features - Enabled features array (overrides preset) * @param {string} options.rowKey - Unique row identifier key (default: 'id') * @param {Object|boolean} options.pagination - Pagination settings or false to disable * @param {boolean} options.bordered - Show table borders (default: true) * @param {string} options.size - Table size: 'small', 'middle', 'large' (default: 'middle') * @param {string} options.tableId - Unique table identifier for settings persistence * @param {TableStorageAdapter} options.storageAdapter - Custom storage implementation * @param {boolean} options.persistSettings - Enable settings persistence */ constructor(container, options = {}) { // Validate container if (!container) { throw new Error('Container element is required'); } this.container = typeof container === 'string' ? document.querySelector(container) : container; if (!this.container) { throw new Error('Container element not found'); } // Apply preset configuration if specified let presetConfig = {}; if (options.preset && TABLE_PRESETS[options.preset]) { presetConfig = { ...TABLE_PRESETS[options.preset] }; } // Initialize options with defaults, preset, and user options this.options = { data: [], columns: [], rowKey: 'id', pagination: { pageSize: 10, showSizeChanger: true, showTotal: null, current: 1 }, bordered: true, size: 'middle', loading: false, tableId: 'default', persistSettings: false, storageAdapter: null, features: ['sorting', 'filtering', 'pagination', 'columnSettings', 'persistSettings', 'columnResizing', 'columnPinning'], ...presetConfig, ...options }; // Enable features based on configuration this.enabledFeatures = new Set(this.options.features); // Initialize settings manager this.settingsManager = null; if (this.options.persistSettings) { this.settingsManager = new TableSettingsManager( this.options.tableId, this.options.storageAdapter ); } // Internal state this.state = { data: [...this.options.data], filteredData: [...this.options.data], filters: {}, sortState: { column: null, direction: null }, currentPage: this.options.pagination.current || 1, pageSize: this.options.pagination.pageSize || 10, columnWidths: {}, visibleColumns: {}, pinnedColumns: {}, openFilter: null, rangeFilters: {}, dateRangeFilters: {}, fontSize: 'medium', cellPadding: 'standard' }; // Initialize column configurations this.initializeColumns(); // Bind methods this.handleSort = this.handleSort.bind(this); this.handleFilter = this.handleFilter.bind(this); this.handlePageChange = this.handlePageChange.bind(this); this.handleColumnResize = this.handleColumnResize.bind(this); this.handleColumnToggle = this.handleColumnToggle.bind(this); this.handleColumnPin = this.handleColumnPin.bind(this); // Initialize the table this.init(); } /** * Check if a feature is enabled * @param {string} feature - Feature name to check * @returns {boolean} Whether the feature is enabled * @private */ isFeatureEnabled(feature) { return this.enabledFeatures.has(feature); } /** * Initialize column configurations * @private */ initializeColumns() { this.options.columns.forEach(col => { this.state.columnWidths[col.key] = col.width || 150; this.state.visibleColumns[col.key] = true; this.state.pinnedColumns[col.key] = false; }); } checkFixedHeight() { // Check if the container has a fixed height set const containerStyle = getComputedStyle(this.container); const hasFixedHeight = containerStyle.height && containerStyle.height !== 'auto' && containerStyle.height !== '0px'; if (hasFixedHeight) { // Add fixed-height class to enable sticky headers setTimeout(() => { const tableContainer = this.tableContainer.querySelector('.table-container'); if (tableContainer) { tableContainer.classList.add('fixed-height'); } }, 0); } } async init() { this.container.innerHTML = ''; this.container.className = 'excelike-table-wrapper'; this.createStructure(); // Load saved settings after structure is created if (this.settingsManager) { await this.loadSettings(); } this.render(); this.attachEvents(); } createStructure() { // Main container this.tableContainer = document.createElement('div'); this.tableContainer.className = 'excelike-table'; // Check if parent container has fixed height this.checkFixedHeight(); // Loading overlay this.loadingOverlay = document.createElement('div'); this.loadingOverlay.className = 'table-loading'; this.loadingOverlay.style.display = this.options.loading ? 'flex' : 'none'; this.loadingOverlay.innerHTML = '<div>Loading...</div>'; // Table menu this.menuContainer = document.createElement('div'); this.menuContainer.className = 'table-menu-container'; // Table wrapper this.tableWrapper = document.createElement('div'); this.tableWrapper.className = 'table-container'; // Table element this.table = document.createElement('table'); this.table.className = `table ${this.options.bordered ? 'bordered' : ''} ${this.options.size}`; // Table header this.thead = document.createElement('thead'); this.tbody = document.createElement('tbody'); this.table.appendChild(this.thead); this.table.appendChild(this.tbody); this.tableWrapper.appendChild(this.table); // Pagination this.paginationContainer = document.createElement('div'); this.paginationContainer.className = 'enhanced-table-pagination'; // Assemble structure this.tableContainer.appendChild(this.loadingOverlay); this.tableContainer.appendChild(this.menuContainer); this.tableContainer.appendChild(this.tableWrapper); this.tableContainer.appendChild(this.paginationContainer); this.container.appendChild(this.tableContainer); } render() { this.applyFilters(); this.renderMenu(); this.renderHeader(); this.renderBody(); this.renderPagination(); // Apply current styling after rendering this.applyCurrentStyling(); } renderMenu() { // Don't render menu if columnSettings feature is disabled if (!this.isFeatureEnabled('columnSettings')) { this.menuContainer.innerHTML = ''; return; } let menuItems = []; // Clear filters option (moved to top - only if filtering is enabled) if (this.isFeatureEnabled('filtering')) { menuItems.push('<div class="table-menu-item" data-action="clear-all-filters">フィルタ全解除</div>'); } // Column settings menuItems.push('<div class="table-menu-item" data-action="column-settings">表示列設定</div>'); // Font size submenu menuItems.push(` <div class="table-menu-item table-menu-item-submenu" data-action="font-size"> 文字サイズ <span class="submenu-arrow">▶</span> <div class="table-submenu-dropdown"> <div class="table-menu-item ${this.state.fontSize === 'smallest' ? 'table-menu-item-current' : ''}" data-action="font-size" data-size="smallest">最小 ${this.state.fontSize === 'smallest' ? '✓' : ''}</div> <div class="table-menu-item ${this.state.fontSize === 'small' ? 'table-menu-item-current' : ''}" data-action="font-size" data-size="small">小 ${this.state.fontSize === 'small' ? '✓' : ''}</div> <div class="table-menu-item ${this.state.fontSize === 'medium' ? 'table-menu-item-current' : ''}" data-action="font-size" data-size="medium">中 ${this.state.fontSize === 'medium' ? '✓' : ''}</div> <div class="table-menu-item ${this.state.fontSize === 'large' ? 'table-menu-item-current' : ''}" data-action="font-size" data-size="large">大 ${this.state.fontSize === 'large' ? '✓' : ''}</div> <div class="table-menu-item ${this.state.fontSize === 'largest' ? 'table-menu-item-current' : ''}" data-action="font-size" data-size="largest">最大 ${this.state.fontSize === 'largest' ? '✓' : ''}</div> </div> </div> `); // Cell padding submenu menuItems.push(` <div class="table-menu-item table-menu-item-submenu" data-action="cell-padding"> セル内パディング <span class="submenu-arrow">▶</span> <div class="table-submenu-dropdown"> <div class="table-menu-item ${this.state.cellPadding === 'wide' ? 'table-menu-item-current' : ''}" data-action="cell-padding" data-padding="wide">広め ${this.state.cellPadding === 'wide' ? '✓' : ''}</div> <div class="table-menu-item ${this.state.cellPadding === 'standard' ? 'table-menu-item-current' : ''}" data-action="cell-padding" data-padding="standard">標準 ${this.state.cellPadding === 'standard' ? '✓' : ''}</div> <div class="table-menu-item ${this.state.cellPadding === 'narrow' ? 'table-menu-item-current' : ''}" data-action="cell-padding" data-padding="narrow">狭め ${this.state.cellPadding === 'narrow' ? '✓' : ''}</div> </div> </div> `); // LocalStorage clear option (only if persistSettings is enabled) if (this.options.persistSettings) { menuItems.push('<div class="table-menu-item" data-action="clear-localstorage">LocalStorageクリア</div>'); } this.menuContainer.innerHTML = ` <div class="table-menu-wrapper"> <button class="table-menu-btn" data-menu-toggle>⚙️</button> <div class="table-menu-dropdown" style="display: none;"> ${menuItems.join('')} </div> </div> `; } renderHeader() { const headerRow = document.createElement('tr'); const visibleColumns = this.getVisibleColumns(); const pinnedColumns = visibleColumns.filter(col => this.state.pinnedColumns[col.key]); const unpinnedColumns = visibleColumns.filter(col => !this.state.pinnedColumns[col.key]); const orderedColumns = [...pinnedColumns, ...unpinnedColumns]; let leftPosition = 0; orderedColumns.forEach((column, index) => { const th = document.createElement('th'); th.className = 'table-header'; th.style.width = `${this.state.columnWidths[column.key]}px`; // Apply pinning styles if (this.state.pinnedColumns[column.key]) { th.classList.add('pinned-column'); th.style.position = 'sticky'; th.style.left = `${leftPosition}px`; th.style.zIndex = '101'; // Check if this is the last pinned column const isLastPinned = index === pinnedColumns.length - 1; if (isLastPinned) { th.classList.add('last-pinned-column'); } leftPosition += this.state.columnWidths[column.key]; } const headerContent = document.createElement('div'); headerContent.className = 'header-content'; const title = document.createElement('span'); title.className = 'header-title'; title.textContent = column.title; // Add tooltip for header this.addTooltipToCell(title, column.title); const controls = document.createElement('div'); controls.className = 'header-controls'; // Sort controls if (column.sortable && this.isFeatureEnabled('sorting')) { const sortControls = this.createSortControls(column); controls.appendChild(sortControls); } // Filter controls if (column.filterable && this.isFeatureEnabled('filtering')) { const filterBtn = this.createFilterButton(column); controls.appendChild(filterBtn); } headerContent.appendChild(title); headerContent.appendChild(controls); // Resize handle (add to headerContent for proper positioning) if (this.isFeatureEnabled('columnResizing')) { const resizeHandle = document.createElement('div'); resizeHandle.className = 'resize-handle'; resizeHandle.dataset.column = column.key; headerContent.appendChild(resizeHandle); } th.appendChild(headerContent); headerRow.appendChild(th); }); this.thead.innerHTML = ''; this.thead.appendChild(headerRow); } createSortControls(column) { const sortControls = document.createElement('div'); sortControls.className = 'sort-controls'; const sortIndicator = document.createElement('div'); sortIndicator.className = 'sort-indicator'; sortIndicator.dataset.column = column.key; const upTriangle = document.createElement('span'); upTriangle.className = `sort-triangle up ${this.state.sortState.column === column.key && this.state.sortState.direction === 'asc' ? 'active' : ''}`; const downTriangle = document.createElement('span'); downTriangle.className = `sort-triangle down ${this.state.sortState.column === column.key && this.state.sortState.direction === 'desc' ? 'active' : ''}`; sortIndicator.appendChild(upTriangle); sortIndicator.appendChild(downTriangle); sortControls.appendChild(sortIndicator); return sortControls; } createFilterButton(column) { const filterContainer = document.createElement('div'); filterContainer.className = 'filter-dropdown-container'; const filterBtn = document.createElement('button'); filterBtn.className = `filter-btn ${this.hasActiveFilter(column.key) ? 'active' : ''}`; filterBtn.dataset.column = column.key; filterBtn.innerHTML = '<span class="filter-funnel"></span>'; filterContainer.appendChild(filterBtn); return filterContainer; } renderBody() { this.tbody.innerHTML = ''; const visibleColumns = this.getVisibleColumns(); const pinnedColumns = visibleColumns.filter(col => this.state.pinnedColumns[col.key]); const unpinnedColumns = visibleColumns.filter(col => !this.state.pinnedColumns[col.key]); const orderedColumns = [...pinnedColumns, ...unpinnedColumns]; const paginatedData = this.getPaginatedData(); paginatedData.forEach(record => { const row = document.createElement('tr'); let leftPosition = 0; orderedColumns.forEach((column, index) => { const td = document.createElement('td'); td.className = 'table-cell'; td.style.width = `${this.state.columnWidths[column.key]}px`; // Apply pinning styles if (this.state.pinnedColumns[column.key]) { td.classList.add('pinned-column'); td.style.position = 'sticky'; td.style.left = `${leftPosition}px`; td.style.zIndex = '100'; // Check if this is the last pinned column const isLastPinned = index === pinnedColumns.length - 1; if (isLastPinned) { td.classList.add('last-pinned-column'); } leftPosition += this.state.columnWidths[column.key]; } const value = record[column.dataIndex]; const content = column.render ? column.render(value, record) : value; if (typeof content === 'string') { td.textContent = content; } else { td.innerHTML = content; } // Add tooltip for overflowing content this.addTooltipToCell(td, content); row.appendChild(td); }); this.tbody.appendChild(row); }); } renderPagination() { // Don't render pagination if disabled or feature is not enabled if (!this.isFeatureEnabled('pagination') || this.options.pagination === false) { this.paginationContainer.innerHTML = ''; return; } const total = this.state.filteredData.length; const totalPages = Math.ceil(total / this.state.pageSize); const start = (this.state.currentPage - 1) * this.state.pageSize + 1; const end = Math.min(this.state.currentPage * this.state.pageSize, total); let paginationHTML = ''; // Info if (this.options.pagination.showTotal) { const totalText = this.options.pagination.showTotal(total, [start, end]); paginationHTML += `<div class="pagination-info">${totalText}</div>`; } else { paginationHTML += `<div class="pagination-info">${start}-${end} of ${total} items</div>`; } // Controls paginationHTML += ` <div class="pagination-controls"> <button ${this.state.currentPage <= 1 ? 'disabled' : ''} data-page="${this.state.currentPage - 1}">Previous</button> <span class="page-info">Page ${this.state.currentPage} of ${totalPages}</span> <button ${this.state.currentPage >= totalPages ? 'disabled' : ''} data-page="${this.state.currentPage + 1}">Next</button> `; // Page size selector if (this.options.pagination.showSizeChanger) { paginationHTML += ` <div class="page-size-selector"> <select class="page-size-select"> <option value="5" ${this.state.pageSize === 5 ? 'selected' : ''}>5 / page</option> <option value="10" ${this.state.pageSize === 10 ? 'selected' : ''}>10 / page</option> <option value="20" ${this.state.pageSize === 20 ? 'selected' : ''}>20 / page</option> <option value="50" ${this.state.pageSize === 50 ? 'selected' : ''}>50 / page</option> </select> </div> `; } paginationHTML += '</div>'; this.paginationContainer.innerHTML = paginationHTML; } // Utility methods getVisibleColumns() { return this.options.columns.filter(col => this.state.visibleColumns[col.key]); } getPaginatedData() { const start = (this.state.currentPage - 1) * this.state.pageSize; const end = start + this.state.pageSize; return this.state.filteredData.slice(start, end); } hasActiveFilter(columnKey) { return (this.state.filters[columnKey] && this.state.filters[columnKey].length > 0) || (this.state.rangeFilters[columnKey] && this.state.rangeFilters[columnKey] !== null); } updateFilterButtonState(columnKey) { const filterBtn = this.tableContainer.querySelector(`.filter-btn[data-column="${columnKey}"]`); if (filterBtn) { // Check if ANY data is being filtered (not showing all data) const totalDataCount = this.options.data.length; const filteredDataCount = this.state.filteredData.length; const hasAnyFilter = filteredDataCount < totalDataCount; if (hasAnyFilter) { filterBtn.classList.add('active'); } else { filterBtn.classList.remove('active'); } } } // Event handlers handleSort(columnKey, direction) { if (direction === null) { // Reset sort to none this.state.sortState = { column: null, direction: null }; } else { this.state.sortState = { column: columnKey, direction }; } this.applySorting(); this.render(); this.autoSaveSettings(); } handleFilter(columnKey, filters) { this.state.filters[columnKey] = filters; this.state.currentPage = 1; // Reset to first page this.render(); this.autoSaveSettings(); } handlePageChange(page) { this.state.currentPage = page; this.renderBody(); this.renderPagination(); this.applyCurrentStyling(); // Apply current styling after page change } handleColumnResize(columnKey, newWidth) { this.state.columnWidths[columnKey] = newWidth; this.renderHeader(); this.renderBody(); this.applyCurrentStyling(); // Apply styling after resize this.autoSaveSettings(); } handleColumnToggle(columnKey, visible) { this.state.visibleColumns[columnKey] = visible; this.render(); this.autoSaveSettings(); } handleColumnPin(columnKey, pinned) { this.state.pinnedColumns[columnKey] = pinned; this.render(); this.autoSaveSettings(); } // Data processing methods applyFilters() { let filtered = [...this.state.data]; // Apply all filters Object.keys(this.state.filters).forEach(columnKey => { const filters = this.state.filters[columnKey]; if (filters && filters.length > 0) { const column = this.options.columns.find(col => col.key === columnKey); if (column) { if (column.filterType === 'date-hierarchy') { // Date hierarchy filter logic filtered = filtered.filter(record => { const value = record[column.dataIndex]; if (!value) return false; const date = new Date(value); const year = date.getFullYear().toString(); const month = date.toLocaleString('default', { month: 'long' }); return filters.some(filterValue => { if (filterValue === year) return true; if (filterValue === `${year}-${month}`) return true; return false; }); }); } else if (column.onFilter) { filtered = filtered.filter(record => filters.some(filterValue => column.onFilter(filterValue, record)) ); } else if (this.isNumericColumn(column)) { // Numeric filter logic - compare as numbers filtered = filtered.filter(record => { const value = parseFloat(record[column.dataIndex]); return !isNaN(value) && filters.includes(value); }); } else { // Default filter logic for text columns filtered = filtered.filter(record => filters.includes(record[column.dataIndex]) ); } } } }); // Apply range filters Object.keys(this.state.rangeFilters).forEach(columnKey => { const rangeFilter = this.state.rangeFilters[columnKey]; if (rangeFilter) { const column = this.options.columns.find(col => col.key === columnKey); if (column) { filtered = filtered.filter(record => { const value = parseFloat(record[column.dataIndex]); return !isNaN(value) && value >= rangeFilter.min && value <= rangeFilter.max; }); } } }); this.state.filteredData = filtered; this.applySorting(); } applySorting() { if (this.state.sortState.column && this.state.sortState.direction) { const column = this.options.columns.find(col => col.key === this.state.sortState.column); if (column) { this.state.filteredData.sort((a, b) => { if (column.sorter) { return this.state.sortState.direction === 'asc' ? column.sorter(a, b) : column.sorter(b, a); } else { // Default sorting const aVal = a[column.dataIndex]; const bVal = b[column.dataIndex]; if (aVal < bVal) return this.state.sortState.direction === 'asc' ? -1 : 1; if (aVal > bVal) return this.state.sortState.direction === 'asc' ? 1 : -1; return 0; } }); } } } /** * Apply both filters and sorting for settings restoration * @private */ applyFiltersAndSort() { // Safety check: ensure data exists if (!this.state.data || !Array.isArray(this.state.data)) { console.warn('applyFiltersAndSort: No valid data available'); this.state.filteredData = []; return; } this.applyFilters(); this.applySorting(); // Safety check: ensure filtered data exists after processing if (!this.state.filteredData || !Array.isArray(this.state.filteredData)) { console.warn('applyFiltersAndSort: Filtered data is invalid, resetting to original data'); this.state.filteredData = [...this.state.data]; } } /** * Apply all current styling settings * @private */ applyCurrentStyling() { this.applyFontSize(); this.applyCellPadding(); } // Event attachment attachEvents() { // Sort events this.tableContainer.addEventListener('click', (e) => { if (e.target.closest('.sort-indicator')) { const columnKey = e.target.closest('.sort-indicator').dataset.column; const currentDirection = this.state.sortState.column === columnKey ? this.state.sortState.direction : null; let newDirection; if (currentDirection === null) { newDirection = 'asc'; // None -> Ascending } else if (currentDirection === 'asc') { newDirection = 'desc'; // Ascending -> Descending } else { newDirection = null; // Descending -> None } this.handleSort(columnKey, newDirection); } }); // Filter events this.tableContainer.addEventListener('click', (e) => { if (e.target.closest('.filter-btn')) { const columnKey = e.target.closest('.filter-btn').dataset.column; this.toggleFilter(columnKey, e.target.closest('.filter-btn')); } }); // Pagination events this.tableContainer.addEventListener('click', (e) => { if (e.target.matches('[data-page]')) { const page = parseInt(e.target.dataset.page); this.handlePageChange(page); } }); // Page size change this.tableContainer.addEventListener('change', (e) => { if (e.target.matches('.page-size-select')) { this.state.pageSize = parseInt(e.target.value); this.state.currentPage = 1; // Re-render body and pagination with styling this.renderBody(); this.renderPagination(); this.applyCurrentStyling(); this.autoSaveSettings(); } }); // Menu toggle this.tableContainer.addEventListener('click', (e) => { if (e.target.matches('[data-menu-toggle]')) { const dropdown = e.target.nextElementSibling; dropdown.style.display = dropdown.style.display === 'none' ? 'block' : 'none'; } }); // Close menu when clicking outside this.documentClickHandler = (e) => { const menuWrapper = e.target.closest('.table-menu-wrapper'); if (!menuWrapper) { // Click was outside all menu wrappers, close all dropdowns const allDropdowns = this.tableContainer.querySelectorAll('.table-menu-dropdown'); allDropdowns.forEach(dropdown => { dropdown.style.display = 'none'; }); } }; document.addEventListener('click', this.documentClickHandler); // Prevent menu from closing when clicking inside dropdown this.tableContainer.addEventListener('click', (e) => { if (e.target.closest('.table-menu-dropdown')) { e.stopPropagation(); } }); // Column settings this.tableContainer.addEventListener('click', (e) => { if (e.target.matches('[data-action="column-settings"]')) { this.showColumnSettings(); // Close menu const dropdown = e.target.closest('.table-menu-dropdown'); dropdown.style.display = 'none'; } }); // Clear all filters this.tableContainer.addEventListener('click', (e) => { if (e.target.matches('[data-action="clear-all-filters"]')) { this.clearAllFilters(); // Close menu const dropdown = e.target.closest('.table-menu-dropdown'); dropdown.style.display = 'none'; } }); // Clear LocalStorage this.tableContainer.addEventListener('click', (e) => { if (e.target.matches('[data-action="clear-localstorage"]')) { this.showLocalStorageClearConfirmation(); // Close menu const dropdown = e.target.closest('.table-menu-dropdown'); dropdown.style.display = 'none'; } }); // Font size change this.tableContainer.addEventListener('click', (e) => { if (e.target.matches('[data-action="font-size"][data-size]')) { const size = e.target.dataset.size; this.setFontSize(size); // Close menu const dropdown = e.target.closest('.table-menu-dropdown'); dropdown.style.display = 'none'; } }); // Cell padding change this.tableContainer.addEventListener('click', (e) => { if (e.target.matches('[data-action="cell-padding"][data-padding]')) { const padding = e.target.dataset.padding; this.setCellPadding(padding); // Close menu const dropdown = e.target.closest('.table-menu-dropdown'); dropdown.style.display = 'none'; } }); // Resize events this.attachResizeEvents(); } attachResizeEvents() { let isResizing = false; let currentColumn = null; let startX = 0; let startWidth = 0; let lastClickTime = 0; let lastClickColumn = null; // Track the column from the last click let resizeTooltip = null; // Create resize tooltip element const createResizeTooltip = () => { if (!resizeTooltip) { resizeTooltip = document.createElement('div'); resizeTooltip.className = 'resize-tooltip'; resizeTooltip.style.display = 'none'; document.body.appendChild(resizeTooltip); } return resizeTooltip; }; // Update tooltip position and content const updateTooltip = (x, y, width) => { const tooltip = createResizeTooltip(); const cm = TableUtils.pxToCm(width); tooltip.textContent = `${width}px (${cm}cm)`; // Position tooltip with smart positioning using config const offset = TABLE_CONFIG.SIZES.TOOLTIP_OFFSET; const position = TableUtils.constrainToViewport(tooltip, x, y, offset.x, -offset.y); tooltip.style.left = `${position.x}px`; tooltip.style.top = `${position.y}px`; tooltip.style.display = 'block'; tooltip.style.opacity = '1'; }; // Hide tooltip const hideTooltip = () => { if (resizeTooltip) { resizeTooltip.style.opacity = '0'; setTimeout(() => { if (resizeTooltip) { resizeTooltip.style.display = 'none'; } }, TABLE_CONFIG.TIMING.TOOLTIP_FADE); } }; this.tableContainer.addEventListener('mousedown', (e) => { if (e.target.matches('.resize-handle')) { const currentTime = Date.now(); const columnKey = e.target.dataset.column; // Check for double click const timeDiff = currentTime - lastClickTime; if (timeDiff < TABLE_CONFIG.SIZES.DOUBLE_CLICK_THRESHOLD && lastClickColumn === columnKey) { // Double click - auto resize this.autoResizeColumn(columnKey); hideTooltip(); e.preventDefault(); // Reset click tracking lastClickTime = 0; lastClickColumn = null; return; } // Update click tracking lastClickTime = currentTime; lastClickColumn = columnKey; isResizing = true; currentColumn = columnKey; startX = e.clientX; startWidth = this.state.columnWidths[currentColumn]; document.body.style.cursor = 'col-resize'; // Show initial tooltip updateTooltip(e.clientX, e.clientY, startWidth); e.preventDefault(); } }); document.addEventListener('mousemove', (e) => { if (isResizing) { const diff = e.clientX - startX; const newWidth = Math.max(TABLE_CONFIG.SIZES.MIN_COLUMN_WIDTH, startWidth + diff); this.handleColumnResize(currentColumn, newWidth); // Update tooltip updateTooltip(e.clientX, e.clientY, newWidth); } }); document.addEventListener('mouseup', () => { if (isResizing) { isResizing = false; currentColumn = null; document.body.style.cursor = ''; hideTooltip(); } }); } autoResizeColumn(columnKey) { const column = this.options.columns.find(col => col.key === columnKey); if (!column) return; // Create a temporary element to measure text width const tempElement = document.createElement('div'); tempElement.style.position = 'absolute'; tempElement.style.visibility = 'hidden'; tempElement.style.whiteSpace = 'nowrap'; tempElement.style.fontSize = getComputedStyle(this.tableContainer).fontSize; tempElement.style.fontFamily = getComputedStyle(this.tableContainer).fontFamily; tempElement.style.padding = '8px 12px'; // Match cell padding document.body.appendChild(tempElement); let maxWidth = 0; // Measure header text width tempElement.textContent = column.title || column.key; const headerWidth = tempElement.offsetWidth + 40; // Add space for sort/filter icons maxWidth = Math.max(maxWidth, headerWidth); // Measure data cell content width const visibleData = this.getCurrentPageData(); visibleData.forEach(row => { let cellContent = row[column.dataIndex] || ''; // Apply render function if exists if (column.render && typeof column.render === 'function') { cellContent = column.render(cellContent, row); // Remove HTML tags for measurement if (typeof cellContent === 'string') { cellContent = cellContent.replace(/<[^>]*>/g, ''); } } tempElement.textContent = String(cellContent); const cellWidth = tempElement.offsetWidth; maxWidth = Math.max(maxWidth, cellWidth); }); // Clean up document.body.removeChild(tempElement); // Set minimum and maximum width limits const newWidth = Math.max( TABLE_CONFIG.SIZES.AUTO_RESIZE_MIN, Math.min(maxWidth, TABLE_CONFIG.SIZES.AUTO_RESIZE_MAX) ); // Apply the new width this.handleColumnResize(columnKey, newWidth); } getCurrentPageData() { const start = (this.state.currentPage - 1) * this.state.pageSize; const end = start + this.state.pageSize; return this.state.filteredData.slice(start, end); } /** * Save current table settings to storage * @returns {Promise<boolean>} Save success status */ async saveSettings() { if (!this.settingsManager) { return false; } return await this.settingsManager.saveSettings(this); } /** * Load and apply saved settings from storage * @returns {Promise<boolean>} Load success status */ async loadSettings() { if (!this.settingsManager) { return false; } const success = await this.settingsManager.loadSettings(this); if (success) { // No need to reapply filters/sorting since they're not persisted this.render(); } return success; } /** * Clear saved settings from storage * @returns {Promise<boolean>} Clear success status */ async clearSettings() { if (!this.settingsManager) { return false; } return await this.settingsManager.clearSettings(); } /** * Auto-save settings when state changes * @private */ async autoSaveSettings() { if (this.settingsManager && this.options.persistSettings) { // Debounce auto-save to avoid excessive saves clearTimeout(this._autoSaveTimeout); this._autoSaveTimeout = setTimeout(async () => { await this.saveSettings(); }, 1000); } } // Filter dropdown implementation toggleFilter(columnKey, button) { if (this.state.openFilter === columnKey) { this.closeFilter(); return; } this.closeFilter(); this.state.openFilter = columnKey; const column = this.options.columns.find(col => col.key === columnKey); this.showFilterDropdown(column, button); } showFilterDropdown(column, button) { // Store original state for cancel functionality const originalFilters = JSON.parse(JSON.stringify(this.state.filters)); const originalRangeFilters = JSON.parse(JSON.stringify(this.state.rangeFilters)); const originalDateRangeFilters = JSON.parse(JSON.stringify(this.state.dateRangeFilters)); const originalFilteredData = [...this.state.filteredData]; // Create filter dropdown const dropdown = document.createElement('div'); dropdown.className = 'filter-dropdown-wrapper'; dropdown.style.position = 'fixed'; dropdown.style.zIndex = '9999'; // Get unique values for the column const uniqueValues = [...new Set(this.state.data.map(item => item[column.dataIndex]))]; const cur