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
JavaScript
/**
* 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