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!
3,736 lines • 132 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();
}
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';
}
});
// Column settings
this.tableContainer.addEventListener('click', (e) => {
if (e.target.matches('[data-action="column-settings"]')) {
this.showColumnSettings();
}
});
// 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 currentFilters = this.state.filters[column.key] || [];
console.log('📊 Unique values for', column.key + ':', uniqueValues.slice(0, 5), '...(', uniqueValues.length, 'total)');
let dropdownContent = '';
// Different filter types
if (column.filterType === 'date-hierarchy') {
dropdownContent = this.createDateHierarchyFilter(uniqueValues, currentFilters, column);
} else if (this.isNumericColumn(column)) {
dropdownContent = this.createRangeFilter(column, uniqueValues);
} else {
dropdownContent = this.createTextFilter(uniqueValues, currentFilters, column);
}
dropdown.innerHTML = `
<div class="filter-dropdown">
<div class="filter-dropdown-header">
<div class="filter-dropdown-title">${column.title}</div>
</div>
${dropdownContent}
<div class="filter-dropdown-footer">
<button class="filter-btn-clear-column">
🔄 この列のフィルタをクリア
</button>
<div class="filter-footer-actions">
<button class="filter-btn-cancel">キャンセル</button>
<button class="filter-btn-confirm">OK</button>
</div>
</div>
</div>
`;
console.log('📄 Generated dropdown HTML preview (first 500 chars):');
console.log(dropdown.innerHTML.substring(0, 500) + '...');
// Position dropdown
document.body.appendChild(dropdown);
// Get column header element to position next to the entire column
const columnHeader = button.closest('.table-header');
const headerRect = columnHeader ? columnHeader.getBoundingClientRect() : button.getBoundingClientRect();
console.log('📍 Header rect:', headerRect);
const dropdownRect = dropdown.getBoundingClientRect();
const viewportWidth = window.innerWidth;
const viewportHeight = window.innerHeight;
// Set maximum height to use available viewport height with margins
const topMargin = 20;
const bottomMargin = 20;
const maxHeight = viewportHeight - topMargin - bottomMargin;
dropdown.style.maxHeight = `${maxHeight}px`;
// Force dropdown content area to use available height
const contentArea = dropdown.querySelector('.filter-dropdown-content, .date-filter-content');
if (contentArea) {
// Calculate height for content area (total - header - footer)
const headerHeight = dropdown.querySelector('.filter-dropdown-header')?.offsetHeight || 0;
const searchHeight = dropdown.querySelector('.filter-dropdown-search')?.offsetHeight || 0;
const rangeHeight = dropdown.querySelector('.filter-dropdown-range')?.offsetHeight || 0;
const footerHeight = dropdown.querySelector('.filter-dropdown-footer')?.offsetHeight || 0;
const contentMaxHeight = maxHeight - headerHeight - searchHeight - rangeHeight - footerHeight - 40; // 40px for padding/margins
contentArea.style.maxHeight = `${Math.max(200, contentMaxHeight)}px`;
}
// Re-measure dropdown after height adjustments
const updatedDropdownRect = dropdown.getBoundingClientRect();
// Calculate horizontal position - prefer right side of column
let left = headerRect.right + 5; // 5px gap from column
// Check if dropdown fits on the right side
if (left + updatedDropdownRect.width > viewportWidth) {
// Not enough space on right, try left side
left = headerRect.left - updatedDropdownRect.width - 5; // 5px gap from column
// If still doesn't fit on left, align to viewport edges
if (left < 5) {
left = 5; // Minimum left margin
}
}
// Calculate vertical position - center to column header with viewport constraints
let top = headerRect.top + (headerRect.height - updatedDropdownRect.height) / 2;
// Ensure dropdown stays within viewport with margins
if (top < topMargin) {
top = topMargin;
} else if (top + updatedDropdownRect.height > viewportHeight - bottomMargin) {
top = viewportHeight - updatedDropdownRect.height - bottomMargin;
}
dropdown.style.left = `${left}px`;
dropdown.style.top = `${top}px`;
// Add event listeners
this.attachFilterEvents(dropdown, column, originalFilters, originalRangeFilters, originalDateRangeFilters, originalFilteredData);
}
createTextFilter(uniqueValues, currentFilters, column) {
const isAllSelected = currentFilters.length === 0;
// Sort values in ascending order
const sortedValues = [...uniqueValues].sort((a, b) => {
if (a === null || a === undefined || a === '') return 1;
if (b === null || b === undefined || b === '') return -1;
return String(a).localeCompare(String(b));
});
return `
<div class="filter-dropdown-search">
<input type="text" class="filter-search-input" placeholder="検索...">
</div>
<div class="filter-dropdown-content">
<div class="filter-option select-all">
<label class="filter-option-label">
<input type="checkbox" class="filter-checkbox" data-value="__all__" ${isAllSelected ? 'checked' : ''}>
<span class="filter-option-text">(すべて選択) <span class="filter-option-count">(${sortedValues.length})</span></span>
</label>
</div>
<div class="filter-options-list">
${sortedValues.map(value => `
<div class="filter-option">
<label class="filter-option-label">
<input type="checkbox" class="filter-checkbox" data-value="${value}" ${isAllSelected || currentFilters.includes(value) ? 'checked' : ''}>
<span class="filter-option-text">${value || '(Empty)'} <span class="filter-option-count">(${this.countDataItems(column, value)})</span></span>
</label>
</div>
`).join('')}
</div>
</div>
`;
}
// Helper function to count data items for filter options
countDataItems(column, value) {
if (!this.state.data || !column) return 0;
// Check if this is a date column and handle date-related values
if (column.filterType === 'date-hierarchy' || this.isDateValue(value)) {
return this.state.data.filter(row => {
const cellValue = row[column.dataIndex];
if (!cellValue) return false;
const cellDateStr = cellValue.toString();
if (value.length === 4 && /^\d{4}$/.test(value)) {
// Year only (YYYY) - check if date starts with this year
return cellDateStr.startsWith(value);
} else if (value.length === 7 && /^\d{4}-\d{2}$/.test(value)) {
// Year-Month (YYYY-MM) - check if date starts with this year-month
return cellDateStr.startsWith(value);
} else if (value.includes('-')) {
// Full date (YYYY-MM-DD) - exact match
return cellDateStr === value;
} else {
// Month name (e.g., "January") - convert to check against year-month format
const yearContext = this.getCurrentYearContext(column, value);
if (yearContext) {
return cellDateStr.startsWith(yearContext);
}
return false;
}
}).length;
} else {
// Handle regular values (non-date)
return this.state.data.filter(row => {
const cellValue = row[column.dataIndex];
return cellValue == value;
}).length;
}
}
// Helper function to check if a value looks like a date
isDateValue(value) {
if (typeof value !== 'string') return false;
// Check for year (4 digits), year-month (YYYY-MM), or full date (YYYY-MM-DD)
return /^\d{4}(-\d{2})?(-\d{2})?$/.test(value);
}
// Helper function to count data items for a specific month name within a year
countDataItemsForMonth(column, year, monthName) {
if (!this.state.data || !column) return 0;
return this.state.data.filter(row => {
const cellValue = row[column.dataIndex];
if (!cellValue) return false;
const date = new Date(cellValue);
const itemYear = date.getFullYear();
const itemMonth = date.toLocaleString('default', { month: 'long' });
return itemYear == year && itemMonth === monthName;
}).length;
}
// Helper function to get year context for month names
getCurrentYearContext(column, monthName) {
// Find the first data entry with this month name to get the year context
const sampleData = this.state.data.find(row => {
const cellValue = row[column.dataIndex];
if (!cellValue) return false;
const date = new Date(cellValue);
const month = date.toLocaleString('default', { month: 'long' });
return month === monthName;
});
if (sampleData) {
const date = new Date(sampleData[column.dataIndex]);
const year = date.getFullYear();
const monthNum = String(date.getMonth() + 1).padStart(2, '0');
return `${year}-${monthNum}`;
}
return null;
}
createRangeFilter(column, uniqueValues) {
const numericValues = uniqueValues.filter(v => !isNaN(v)).map(Number);
const min = Math.min(...numericValues);
const max = Math.max(...numericValues);
// Detect decimal places from data
const decimalPlaces = TableUtils.getDecimalPlaces(uniqueValues.filter(v => !isNaN(v)).map(Number));
const currentRange = this.state.rangeFilters[column.key] || { min, max };
const currentFilters = this.state.filters[column.key] || [];
// Sort unique values in ascending order
const sortedValues = [...uniqueValues].sort((a, b) => Number(a) - Number(b));
// Determine "Select All" checkbox state
const isAllSelected = currentFilters.length === 0;
const isNoneSelected = currentFilters.length === 0 && this.state.filters[column.key] && this.state.filters[column.key].length === 0 && this.hasActiveFilter(column.key);
const isPartiallySelected = currentFilters.length > 0 && currentFilters.length < sortedValues.length;
let selectAllState = '';
let selectAllChecked = '';
if (isAllSelected && !this.hasActiveFilter(column.key)) {
selectAllChecked = 'checked';
} else if (isPartiallySelected) {
selectAllState = 'indeterminate';
}
return `
<div class="filter-dropdown-range">
<div class="range-filter">
<div class="range-inputs">
<div class="range-input-group">
<label class="range-label">Min</label>
<input type="number" class="range-input range-min" value="${TableUtils.formatNumberValue(currentRange.min, decimalPlaces)}" min="${min}" max="${max}" data-min="${min}" data-max="${max}" data-decimal-places="${decimalPlaces}">
</div>
<div class="range-input-group">
<label class="range-label">Max</label>
<input type="number" class="range-input range-max" value="${TableUtils.formatNumberValue(currentRange.max, decimalPlaces)}" min="${min}" max="${max}" data-min="${min}" data-max="${max}" data-decimal-places="${decimalPlaces}">
</div>
</div>
<div class="range-slider-container">
<div class="range-slider" data-min="${min}" data-max="${max}">
<div class="range-track"></div>
<div class="range-thumb range-thumb-min" data-type="min"></div>
<div class="range-thumb range-thumb-max" data-type="max"></div>
</div>
</div>
</div>
</div>
<div class="filter-dropdown-search">
<input type="text" class="filter-search-input" placeholder="検索...">
</div>
<div class="filter-dropdown-content">
<div class="filter-option select-all">
<label class="filter-option-label">
<input type="checkbox" class="filter-checkbox" data-value="__all__" ${selectAllChecked} data-state="${selectAllState}">
<span class="filter-option-text">(すべて選択) <span class="filter-option-count">(${sortedValues.length})</span></span>
</label>
</div>
<div class="filter-options-list">
${sortedValues.map(value => `
<div class="filter-option">
<label class="filter-option-label">
<input type="checkbox" class="filter-checkbox" data-value="${value}" ${(currentFilters.length === 0 && !this.hasActiveFilter(column.key)) || currentFilters.includes(Number(value)) ? 'checked' : ''}>
<span class="filter-option-text">${value} <span class="filter-option-count">(${this.countDataItems(column, value)})</span></span>
</label>
</div>
`).join('')}
</div>
</div>
`;
}
createDateHierarchyFilter(values, currentFilters, column) {
// Group dates by year/month/day
const dateGroups = this.groupDatesByHierarchy(values);
const isAllSelected = currentFilters.length === 0;
// Calculate date range for slider
const dateValues = values.filter(v => v && !isNaN(new Date(v).getTime()));
const minDate = dateValues.length > 0 ? new Date(Math.min(...dateValues.map(d => new Date(d).getTime()))).toISOString().split('T')[0] : '';
const maxDate = dateValues.length > 0 ? new Date(Math.max(...dateValues.map(d => new Date(d).getTime()))).toISOString().split('T')[0] : '';
const currentDateRange = this.state.dateRangeFilters[column?.key] || { startDate: minDate, endDate: maxDate };
return `
<div class="filter-dropdown-range">
<div class="date-range-filter">
<div class="date-range-inputs">
<div class="date-range-input-group">
<label class="date-range-label">開始日</label>
<input type="date" class="date-range-input date-range-min" value="${currentDateRange.startDate || minDate}" min="${minDate}" max="${maxDate}">
</div>
<div class="date-range-input-group">
<label class="date-range-label">終了日</label>
<input type="date" class="date-range-input date-range-max" value="${currentDateRange.endDate || maxDate}" min="${minDate}" max="${maxDate}">
</div>
</div>
<div class="date-range-slider-container">
<div class="date-range-slider" data-min="${minDate}" data-max="${maxDate}">
<div class="date-range-track"></div>
<div class="date-range-thumb date-range-thumb-min" data-type="min"></div>
<div class="date-range-thumb date-range-thumb-max" data-type="max"></div>
</div>
</div>
</div>
</div>
<div class="filter-dropdown-search">
<input type="text" class="filter-search-input" placeholder="検索...">
</div>
<div class="date-expand-controls">
<div class="expand-control-header">
<span class="expand-control-label">展開表示:</span>
<div class="expand-control-buttons">
<button class="expand-control-btn active" data-level="year">年</button>
<button class="expand-control-btn" data-level="month">月</button>
<button class="expand-control-btn" data-level="day">日</button>
</div>
</div>
</div>
<div class="filter-dropdown-content">
<div class="filter-option select-all">
<label class="filter-option-label">
<input type="checkbox" class="filter-checkbox" data-value="__all__" ${isAllSelected ? 'checked' : ''}>
<span class="filter-option-text">(すべて選択) <span class="filter-option-count">(${values.length})</span></span>
</label>
</div>
<div class="date-filter-content">
${Object.keys(dateGroups).map(year => `
<div class="date-filter-group">
<div class="date-filter-group-header">
<button class="expand-btn">▼</button>
<label class="date-filter-label">
<input type="checkbox" class="date-filter-checkbox" data-value="${year}" ${isAllSelected || this.isYearSelected(year, dateGroups[year], currentFilters) ? 'checked' : ''}>
<span class="date-filter-text">${year} <span class="filter-option-count">(${this.countDataItems(column, year)})</span></span>
</label>
</div>
<div class="date-filter-children">
${Object.keys(dateGroups[year]).map(month => `
<div class="date-filter-item date-filter-month">
<div class="date-filter-month-header">
<button class="expand-btn expand-btn-month">▼</button>
<label class="date-filter-label">
<input type="checkbox" class="date-filter-checkbox" data-value="${year}-${month}" ${isAllSelected || this.isMonthSelected(year, month, dateGroups[year][month], currentFilters) ? 'checked' : ''}>
<span class="date-filter-text">${month} <span class="filter-option-count">(${this.countDataItemsForMonth(column, year, month)})</span></span>
</label>
</div>
<div class="date-filter-days">
${dateGroups[year][month].map(dateValue => `
<div class="date-filter-item date-filter-day">
<label class="date-filter-label">
<input type="checkbox" class="date-filter-checkbox" data-value="${dateValue}" ${isAllSelected || currentFilters.includes(dateValue) ? 'checked' : ''}>
<span class="date-filter-text">${new Date(dateValue).getDate()}日 <span class="filter-option-count">(${this.countDataItems(column, dateValue)})</span></span>
</label>
</div>
`).join('')}
</div>
</div>
`).join('')}
</div>
</div>
`).join('')}
</div>
</div>
`;
}
attachFilterEvents(dropdown, column, originalFilters, originalRangeFilters, originalDateRangeFilters, originalFilteredData) {
// Real-time filtering function
const applyRealtimeFilter = () => {
if (this.isNumericColumn(column)) {
// Handle numeric filter - use checkboxes as primary filter method
const selectedValues = Array.from(dropdown.querySelectorAll('.filter-checkbox:checked'))
.map(cb => cb.dataset.value)
.filter(val => val !== '__all__')
.map(val => parseFloat(val))
.filter(val => !isNaN(val));
// Clear range filter to avoid conflicts
delete this.state.rangeFilters[column.key];
// If all values are selected, treat as no filter
const allCheckboxes = dropdown.querySelectorAll('.filter-checkbox:not([data-value="__all__"])');
if (selectedValues.length === allCheckboxes.length) {
this.handleFilter(column.key, []);
} else {
// Use numeric values directly for filtering
this.handleFilter(column.key, selectedValues);
}
} else if (column.filterType === 'date-hierarchy') {
// Handle date hierarchy filter
const selectedValues = Array.from(dropdown.querySelectorAll('.filter-checkbox:checked, .date-filter-checkbox:checked'))
.map(cb => cb.dataset.value)
.filter(val => val !== '__all__');
// Store date range filter
const minInput = dropdown.querySelector('.date-range-min');
const maxInput = dropdown.querySelector('.date-range-max');
if (minInput && maxInput) {
this.state.dateRangeFilters[column.key] = {
startDate: minInput.value,
endDate: maxInput.value
};
}
// If all values are selected, treat as no filter
const allCheckboxes = dropdown.querySelectorAll('.filter-checkbox:not([data-value="__all__"]), .date-filter-checkbox');
if (selectedValues.length === allCheckboxes.length) {
this.handleFilter(column.key, []);
} else {
this.handleFilter(column.key, selectedValues);
}
} else {
// Handle regular text filter
const selectedValues = Array.from(dropdown.querySelectorAll('.filter-checkbox:checked'))
.map(cb => cb.dataset.value)
.filter(val => val !== '__all__');
// If all values are selected, treat as no filter
const allCheckboxes = dropdown.querySelectorAll('.filter-checkbox:not([data-value="__all__"])');
if (selectedValues.length === allCheckboxes.length) {
this.handleFilter(column.key, []);
} else {
this.handleFilter(column.key, selectedValues);
}
}
// Update filter button state after applying real-time filter
this.updateFilterButtonState(column.key);
};
// Cancel button - restore original state
dropdown.querySelector('.filter-btn-cancel').addEventListener('click', () => {
console.log('🔍 Cancel - Before restore:', {
filteredDataCount: this.state.filteredData.length,
totalDataCount: this.options.data.length,
hasAnyFilter: this.state.filteredData.length < this.options.data.length
});
this.state.filters = originalFilters;
this.state.rangeFilters = originalRangeFilters;
this.state.dateRangeFilters = originalDateRangeFilters;
this.state.filteredData = originalFilteredData;
this.state.currentPage = 1;
console.log('🔍 Cancel - After restore:', {
filteredDataCount: this.state.filteredData.length,
totalDataCount: this.options.data.length,
hasAnyFilter: this.state.filteredData.length < this.options.data.length
});
this.renderBody();
this.renderPagination();
this.closeFilter();
// Update filter button state after state restoration is complete
this.updateFilterButtonState(column.key);
});
// Clear column filter button
const clearBtn = dropdown.querySelector('.filter-btn-clear-column');
if (clearBtn) {
clearBtn.addEventListener('click', () => {
this.state.filters[column.key] = [];
this.state.rangeFilters[column.key] = null;
this.render();
this.closeFilter();
this.autoSaveSettings();
});
}
// OK button
dropdown.querySelector('.filter-btn-confirm').addEventListener('click', () => {
if (this.isNumericColumn(column)) {
// Handle numeric filter - use checkboxes as primary filter method
const selectedValues = Array.from(dropdown.querySelectorAll('.filter-checkbox:checked'))
.map(cb => cb.dataset.value)
.filter(val => val !== '__all__')
.map(val => parseFloat(val))
.filter(val => !isNaN(val));
// Clear range filter to avoid conflicts
delete this.state.rangeFilters[column.key];
// If all values are selected, treat as no filter
const allCheckboxes = dropdown.querySelectorAll('.filter-checkbox:not([data-value="__all__"])');
if (selectedValues.length === allCheckboxes.length) {
this.handleFilter(column.key, []);
} else {
// Use numeric values directly for filtering
this.handleFilter(column.key, selectedValues);
}
} else if (column.filterType === 'date-hierarchy') {
// Handle date hierarchy filter
const selectedValues = Array.from(dropdown.querySelectorAll('.filter-checkbox:checked, .date-filter-checkbox:checked'))
.map(cb => cb.dataset.value)
.filter(val => val !== '__all__');
// Store date range filter
const minInput = dropdown.querySelector('.date-range-min');
const maxInput = dropdown.querySelector('.date-range-max');
if (minInput && maxInput) {
this.state.dateRangeFilters[column.key] = {
startDate: minInput.value,
endDate: maxInput.value
};
}
// If all values are selected, treat as no filter
const allCheckboxes = dropdown.querySelectorAll('.filter-checkbox:not([data-value="__all__"]), .date-filter-checkbox');
if (selectedValues.length === allCheckboxes.length) {
this.handleFilter(column.key, []);
} else {
this.handleFilter(column.key, selectedValues);
}
} else {
// Handle regular text filter
const selectedValues = Array.from(dropdown.querySelectorAll('.filter-checkbox:checked'))
.map(cb => cb.dataset.value)
.filter(val => val !== '__all__');
// If all values are selected, treat as no filter
const allCheckboxes = dropdown.querySelectorAll('.filter-checkbox:not([data-value="__all__"])');
if (selectedValues.length === allCheckboxes.length) {
this.handleFilter(column.key, []);
} else {
this.handleFilter(column.key, selectedValues);
}
}
// Update filter button state after final filter application
this.updateFilterButtonState(column.key);
this.closeFilter();
});
// Function to update OK button state
const updateOkButtonState = () => {
const confirmBtn = dropdown.querySelector('.filter-btn-confirm');
if (!confirmBtn) return;
const checkedBoxes = dropdown.querySelectorAll('.filter-checkbox:checked:not([data-value="__all__"]), .date-filter-checkbox:checked');
const hasCheckedItems = checkedBoxes.length > 0;
confirmBtn.disabled = !hasCheckedItems;
};
// Select all functionality
const selectAllCheckbox = dropdown.querySelector('[data-value="__all__"]');
if (selectAllCheckbox) {
selectAllCheckbox.addEventListener('change', (e) => {
const checkboxes = dropdown.querySelectorAll('.filter-checkbox:not([data-value="__all__"]), .date-filter-checkbox');
checkboxes.forEach(cb => cb.checked = e.target.checked);
// Reset indeterminate state
e.target.indeterminate = false;
// For numeric columns, don't update slider range when "select all" changes
// The slider should maintain its user-set range
// Update OK button state
updateOkButtonState();
// Apply real-time filtering
applyRealtimeFilter();
});
}
// Individual checkbox change events (exclude date hierarchy checkboxes handled separately)
let checkboxSelector = '.filter-checkbox:not([data-value="__all__"])';
if (column.filterType !== 'date-hierarchy') {
checkboxSelector += ', .date-filter-checkbox';
}
const allCheckboxes = dropdown.querySelectorAll(checkboxSelector);
allCheckboxes.forEach(cb => {
cb.addEventListener('change', () => {
// Update "Select All" checkbox state for all filter types
if (this.isNumericColumn(column)) {
this.updateRangeFromCheckbox(dropdown, column);
} else {
// For non-numeric columns, update select all checkbox state
if (selectAllCheckbox) {
const totalCheckboxes = dropdown.querySelectorAll('.filter-checkbox:not([data-value="__all__"]), .date-filter-checkbox');
const checkedCheckboxes = dropdown.querySelectorAll('.filter-checkbox:checked:not([data-value="__all__"]), .date-filter-checkbox:checked');
TableUtils.updateSelectAllState(selectAllCheckbox, allCheckboxes);
}
}
// Update OK button state
updateOkButtonState();
// Apply real-time filtering
applyRealtimeFilter();
});
});
// Initial OK button state
updateOkButtonState();
// Search functionality
const searchInput = dropdown.querySelector('.filter-search-input');
if (searchInput) {
searchInput.addEventListener('input', (e) => {
const searchTerm = e.target.value.toLowerCase();
const options = dropdown.querySelectorAll('.filter-option:not(.select-all), .date-filter-group, .date-filter-item');
options.forEach(option => {
const text = option.querySelector('.filter-option-text, .date-filter-text')?.textContent.toLowerCase() || '';
option.style.display = text.includes(searchTerm) ? 'block' : 'none';
});
});
}
// Range slider functionality
this.attachRangeSliderEvents(dropdown, column, applyRealtimeFilter);
// Date range slider functionality
this.attachDateRangeSliderEvents(dropdown, column, applyRealtimeFilter);
// Date hierarchy expand/collapse (only for date columns)
if (column.filterType === 'date-hierarchy') {
this.attachDateHierarchyEvents(dropdown, column, applyRealtimeFilter);
} else {
}
// Set initial "Select All" checkbox state after dropdown is created
this.updateSelectAllCheckboxState(dropdown, column);
console.log('🎊 attachFilterEvents completed for column:', column.key, 'filterType:', column.filterType);
}
updateSelectAllCheckboxState(dropdown, column) {
const selectAllCheckbox = dropdown.querySelector('[data-value="__all__"]');
if (!selectAllCheckbox) return;
const allCheckboxes = dropdown.querySelectorAll('.filter-checkbox:not([data-value="__all__"]), .date-filter-checkbox');
const checkedCheckboxes = dropdown.querySelectorAll('.filter-checkbox:checked:not([data-value="__all__"]), .date-filter-checkbox:checked');
// Handle indeterminate state from data attribute
const stateAttr = selectAllCheckbox.dataset.state;
if (stateAttr === 'indeterminate') {
selectAllCheckbox.indeterminate = true;
selectAllCheckbox.checked = false;
} else {
// Normal state handling
if (checkedCheckboxes.length === 0) {
selectAllCheckbox.checked = false;
selectAllCheckbox.indeterminate = false;
} else if (checkedCheckboxes.length === allCheckboxes.length) {
selectAllCheckbox.checked = true;
selectAllCheckbox.indeterminate = false;
} else {
selectAllCheckbox.checked = false;
selectAllCheckbox.indeterminate = true;
}
}
}
updateSliderFromCheckboxes(dropdown, column) {
if (!this.isNumericColumn(column)) return;
const minInput = dropdown.querySelector('.range-min');
const maxInput = dropdown.querySelector('.range-max');
if (!minInput || !maxInput) return;
const checkedCheckboxes = dropdown.querySelectorAll('.filter-checkbox:checked:not([data-value="__all__"])');
if (checkedCheckboxes.length === 0) return;
const checkedValues = Array.from(checkedCheckboxes)
.map(cb => parseFloat(cb.dataset.value))
.filter(val => !isNaN(val));
if (checkedValues.length > 0) {
const minVal = Math.min(...checkedValues);
const maxVal = Math.max(...checkedValues);
const decimalPlaces = parseInt(minInput.dataset.decimalPlaces) || 0;
minInput.value = TableUtils.formatNumberValue(minVal, decimalPlaces);
maxInput.value = TableUtils.formatNumberValue(maxVal, decimalPlaces);
}
}
updateDateSliderFromCheckboxes(dropdown, column) {
const minInput = dropdown.querySelector('.date-range-min');
const maxInput = dropdown.querySelector('.date-range-max');
if (!minInput || !maxInput) return;
const checkedCheckboxes = dropdown.querySelectorAll('.date-filter-checkbox:checked');
if (checkedCheckboxes.length === 0) return;
const checkedDates = Array.from(checkedCheckboxes)
.map(cb => cb.dataset.value)
.filter(val => val && val !== '__all__' && /^\d{4}-\d{2}-\d{2}$/.test(val))
.map(val => new Date(val))
.filter(date => !isNaN(date.getTime()));
if (checkedDates.length > 0) {
const minDate = new Date(Math.min(...checkedDates));
const maxDate = new Date(Math.max(...checkedDates));
minInput.value = minDate.toISOString().split('T')[0];
maxInput.value = maxDate.toISOString().split('T')[0];
}
}
updateDateCheckboxFromRange(dropdown, column) {
const minInput = dropdown.querySelector('.date-range-min');
const maxInput = dropdown.querySelector('.date-range-max');
if (!minInput || !maxInput) return;
const startDate = new Date(minInput.value);
const endDate = new Date(maxInput.value);
if (isNaN(startDate.getTime()) || isNaN(endDate.getTime())) return;
const dateCheckboxes = dropdown.querySelectorAll('.date-filter-checkbox');
dateCheckboxes.forEach(cb => {
const value = cb.dataset.value;
if (value && value !== '__all__') {
// Handle individual date values (YYYY-MM-DD format)
if (/^\d{4}-\d{2}-\d{2}$/.test(value)) {
const date = new Date(value);
cb.checked = date >= startDate && date <= endDate;
}
// Handle year values (YYYY format)
else if (/^\d{4}$/.test(value)) {
const year = parseInt(value);
const startYear = startDate.getFullYear();
const endYear = endDate.getFullYear();
// For year checkboxes, don't set state directly - let hierarchy logic handle it
// This prevents conflicts with indeterminate state management
// The year state will be calculated by updateDateParentCheckboxes after individual dates are processed
}
// Handle year-month values (YYYY-MonthName format)
else if (/^\d{4}-.+$/.test(value)) {
const [year, monthName] = value.split('-');
const monthIndex = new Date(Date.parse(monthName + ' 1, 2000')).getMonth();
// For month checkboxes, don't set state directly - let hierarchy logic handle it
// This prevents conflicts with indeterminate state management
// The month state will be calculated by updateDateParentCheckboxes after individual dates are processed
}
}
});
// Update parent checkboxes state
this.updateDateParentCheckboxes(dropdown);
}
/**
* 階層チェックボックスの状態を更新する共通関数
* @param {HTMLElement} parentCheckbox - 親チェックボックス
* @param {NodeList} childCheckboxes - 子チェックボックス一覧
*/
updateHierarchicalCheckboxState(parentCheckbox, childCheckboxes) {
if (!parentCheckbox || !childCheckboxes || childCheckboxes.length === 0) {
return;
}
const checkedCount = Array.from(childCheckboxes).filter(cb => cb.checked).length;
if (checkedCount === 0) {
// すべての子が未選択
parentCheckbox.checked = false;
parentCheckbox.indeterminate = false;
} else if (checkedCount === childCheckboxes.length) {
// すべての子が選択済み
parentCheckbox.checked = true;
parentCheckbox.indeterminate = false;
} else {
// 一部の子のみ選択(中間状態)
parentCheckbox.checked = false;
parentCheckbox.indeterminate = true;
}
}
/**
* 親チェックボックスの変更を子に伝播する共通関数
* @param {HTMLElement} parentCheckbox - 親チェックボックス
* @param {NodeList} childCheckboxes - 子チェックボックス一覧
*/
propagateCheckboxStateToChildren(parentCheckbox, childCheckboxes) {
if (!parentCheckbox || !childCheckboxes) {
return;
}
const isChecked = parentCheckbox.checked;
// すべての子チェックボックスを親と同じ状態にする
Array.from(childCheckboxes).forEach(cb => {
cb.checked = isChecked;
cb.indeterminate = false;
});
// 親のindeterminate状態をクリア
parentCheckbox.indeterminate = false;
}
/**
* 日付階層フィルタの親チェックボックス状態を更新
* @param {HTMLElement} dropdown - フィルタドロップダウン要素
*/
updateDateParentCheckboxes(dropdown) {
// 月チェックボックスの状態を更新(子:日)
const monthGroups = dropdown.querySelectorAll('.date-filter-month');
monthGroups.forEach(month => {
const monthCheckbox = month.querySelector('.date-filter-checkbox');
const dayCheckboxes = month.querySelectorAll('.date-filter-days .date-filter-checkbox');
this.updateHierarchicalCheckboxState(monthCheckbox, dayCheckboxes);
});
// 年チェックボックスの状態を更新(子:月・日)
const yearGroups = dropdown.querySelectorAll('.date-filter-group');
yearGroups.forEach(group => {
const yearCheckbox = group.querySelector('.date-filter-checkbox');
const childCheckboxes = group.querySelectorAll('.date-filter-children .date-filter-checkbox');
this.updateHierarchicalCheckboxState(yearCheckbox, childCheckboxes);
});
}
/**
* 日付階層フィルタのチェックボックス変更処理(共通)
* @param {Event} event - チェックボックス変更イベント
* @param {HTMLElement} dropdown - フィルタドロップダウン要素
* @param {Function} applyFilterCallback - フィルタ適用コールバック
*/
handleDateHierarchyCheckboxChange(event, dropdown, applyFilterCallback) {
const checkbox = event.target;
const level = this.getDateHierarchyLevel(checkbox);
switch (level) {
case 'year':
this.handleYearCheckboxChange(checkbox, dropdown);
break;
case 'month':
this.handleMonthCheckboxChange(checkbox, dropdown);
break;
case 'day':
this.handleDayCheckboxChange(checkbox, dropdown);
break;
default:
console.warn('⚠️ Unknown hierarchy level:', level);
}
// フィルタ適用(遅延実行で連動処理完了後に実行)
if (applyFilterCallback && typeof applyFilterCallback === 'function') {
setTimeout(() => applyFilterCallback(), 0);
}
}
/**
* チェックボックスの階層レベルを判定
* @param {HTMLElement} checkbox - チェックボックス要素
* @returns {string} 'year' | 'month' | 'day'
*/
getDateHierarchyLevel(checkbox) {
if (checkbox.closest('.date-filter-group-header')) {
return 'year';
} else if (checkbox.closest('.date-filter-month-header')) {
return 'month';
} else if (checkbox.closest('.date-filter-day')) {
return 'day';
}
return 'unknown';
}
/**
* 年チェックボックス変更処理
* @param {HTMLElement} yearCheckbox - 年チェックボックス
* @param {HTMLElement} dropdown - フィルタドロップダウン
*/
handleYearCheckboxChange(yearCheckbox, dropdown) {
const yearGroup = yearCheckbox.closest('.date-filter-group');
const monthCheckboxes = yearGroup.querySelectorAll('.date-filter-month .date-filter-checkbox');
const dayCheckboxes = yearGroup.querySelectorAll('.date-filter-day .date-filter-checkbox');
// 年→月・日の連動
this.propagateCheckboxStateToChildren(yearCheckbox, monthCheckboxes);
this.propagateCheckboxStateToChildren(yearCheckbox, dayCheckboxes);
}
/**
* 月チェックボックス変更処理
* @param {HTMLElement} monthCheckbox - 月チェックボックス
* @param {HTMLElement} dropdown - フィルタドロップダウン
*/
handleMonthCheckboxChange(monthCheckbox, dropdown) {
const monthItem = monthCheckbox.closest('.date-filter-month');
const dayCheckboxes = monthItem.querySelectorAll('.date-filter-day .date-filter-checkbox');
// 月→日の連動
this.propagateCheckboxStateToChildren(monthCheckbox, dayCheckboxes);
// 親(年)の状態更新
this.updateDateParentCheckboxes(dropdown);
}
/**
* 日チェックボックス変更処理
* @param {HTMLElement} dayCheckbox - 日チェックボックス
* @param {HTMLElement} dropdown - フィルタドロップダウン
*/
handleDayCheckboxChange(dayCheckbox, dropdown) {
// 親(月・年)の状態更新
this.updateDateParentCheckboxes(dropdown);
}
closeFilter() {
const existingDropdown = document.querySelector('.filter-dropdown-wrapper');
if (existingDropdown) {
existingDropdown.remove();
}
this.state.openFilter = null;
}
showColumnSettings() {
// Create modal overlay
const overlay = document.createElement('div');
overlay.className = 'column-settings-overlay';
const modal = document.createElement('div');
modal.className = 'column-settings-modal';
let modalContent = `
<div class="column-settings-header">
<h3>列設定</h3>
<button class="close-btn">×</button>
</div>
<div class="column-settings-content">
<div class="column-list">
<div class="column-item select-all-item">
<label class="column-label">
<input type="checkbox" class="column-checkbox-all" id="select-all-columns">
<span class="column-text">(すべて選択)</span>
</label>
</div>
${this.options.columns.map(col => `
<div class="column-item">
<label class="column-label">
<input type="checkbox" class="column-checkbox" data-column="${col.key}" ${this.state.visibleColumns[col.key] ? 'checked' : ''}>
<span class="column-text">${col.title}</span>
</label>
<label class="pin-label">
<input type="checkbox" class="pin-checkbox" data-column="${col.key}" ${this.state.pinnedColumns[col.key] ? 'checked' : ''}>
<span class="pin-icon">📌</span>
</label>
</div>
`).join('')}
</div>
</div>
<div class="column-settings-footer">
<button class="cancel-btn">キャンセル</button>
<button class="confirm-btn">OK</button>
</div>
`;
modal.innerHTML = modalContent;
overlay.appendChild(modal);
document.body.appendChild(overlay);
// Event listeners
overlay.querySelector('.close-btn').addEventListener('click', () => overlay.remove());
overlay.querySelector('.cancel-btn').addEventListener('click', () => overlay.remove());
// Select all functionality
const selectAllCheckbox = overlay.querySelector('.column-checkbox-all');
const columnCheckboxes = overlay.querySelectorAll('.column-checkbox');
// Set initial state of "select all" checkbox
const updateSelectAllState = () => {
const checkedCount = Array.from(columnCheckboxes).filter(cb => cb.checked).length;
const totalCount = columnCheckboxes.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;
}
};
// Initial state
updateSelectAllState();
// Select all checkbox event
selectAllCheckbox.addEventListener('change', (e) => {
columnCheckboxes.forEach(cb => cb.checked = e.target.checked);
updateSelectAllState();
});
// Individual checkbox events
columnCheckboxes.forEach(cb => {
cb.addEventListener('change', updateSelectAllState);
});
overlay.querySelector('.confirm-btn').addEventListener('click', () => {
// Apply column visibility changes
overlay.querySelectorAll('.column-checkbox').forEach(cb => {
this.state.visibleColumns[cb.dataset.column] = cb.checked;
});
// Apply column pinning changes
overlay.querySelectorAll('.pin-checkbox').forEach(cb => {
this.state.pinnedColumns[cb.dataset.column] = cb.checked;
});
this.render();
overlay.remove();
});
// Close on overlay click
overlay.addEventListener('click', (e) => {
if (e.target === overlay) overlay.remove();
});
}
attachRangeSliderEvents(dropdown, column, applyRealtimeFilter) {
const slider = dropdown.querySelector('.range-slider');
const track = dropdown.querySelector('.range-track');
const minThumb = dropdown.querySelector('.range-thumb-min');
const maxThumb = dropdown.querySelector('.range-thumb-max');
const minInput = dropdown.querySelector('.range-min');
const maxInput = dropdown.querySelector('.range-max');
if (!slider || !track || !minThumb || !maxThumb || !minInput || !maxInput) return;
// Flag to prevent infinite loops between slider and checkbox updates
let isUpdatingFromSlider = false;
const min = parseFloat(slider.dataset.min);
const max = parseFloat(slider.dataset.max);
const range = max - min;
const updateSlider = () => {
const minVal = parseFloat(minInput.value);
const maxVal = parseFloat(maxInput.value);
const minPercent = ((minVal - min) / range) * 100;
const maxPercent = ((maxVal - min) / range) * 100;
minThumb.style.left = `${minPercent}%`;
maxThumb.style.left = `${maxPercent}%`;
track.style.left = `${minPercent}%`;
track.style.width = `${maxPercent - minPercent}%`;
};
// Set slider range based on currently selected checkboxes
this.updateSliderFromCheckboxes(dropdown, column);
// Initial update
updateSlider();
// Input change events
minInput.addEventListener('input', () => {
if (slider._isUpdatingFromSlider) return;
updateSlider();
this.updateCheckboxFromRange(dropdown, column);
// Apply real-time filtering (no icon state update during real-time)
applyRealtimeFilter();
});
maxInput.addEventListener('input', () => {
if (slider._isUpdatingFromSlider) return;
updateSlider();
this.updateCheckboxFromRange(dropdown, column);
// Apply real-time filtering (no icon state update during real-time)
applyRealtimeFilter();
});
// Thumb drag events
let isDragging = false;
let currentThumb = null;
const startDrag = (e, thumb) => {
isDragging = true;
currentThumb = thumb;
e.preventDefault();
};
const drag = (e) => {
if (!isDragging || !currentThumb) return;
const rect = slider.getBoundingClientRect();
// Handle both mouse and touch events
const clientX = e.clientX || (e.touches && e.touches[0] ? e.touches[0].clientX : 0);
const x = clientX - rect.left;
const percent = Math.max(0, Math.min(100, (x / rect.width) * 100));
const value = min + (percent / 100) * range;
const decimalPlaces = parseInt(minInput.dataset.decimalPlaces) || 0;
if (currentThumb.dataset.type === 'min') {
const maxVal = parseFloat(maxInput.value);
const newMinVal = Math.min(value, maxVal);
minInput.value = TableUtils.formatNumberValue(newMinVal, decimalPlaces);
} else {
const minVal = parseFloat(minInput.value);
const newMaxVal = Math.max(value, minVal);
maxInput.value = TableUtils.formatNumberValue(newMaxVal, decimalPlaces);
}
updateSlider();
this.updateCheckboxFromRange(dropdown, column);
// Apply real-time filtering (no icon state update during real-time)
applyRealtimeFilter();
};
const stopDrag = () => {
isDragging = false;
currentThumb = null;
};
// Mouse events
minThumb.addEventListener('mousedown', (e) => startDrag(e, minThumb));
maxThumb.addEventListener('mousedown', (e) => startDrag(e, maxThumb));
document.addEventListener('mousemove', drag);
document.addEventListener('mouseup', stopDrag);
// Touch events for mobile support
minThumb.addEventListener('touchstart', (e) => startDrag(e, minThumb), { passive: false });
maxThumb.addEventListener('touchstart', (e) => startDrag(e, maxThumb), { passive: false });
document.addEventListener('touchmove', drag, { passive: false });
document.addEventListener('touchend', stopDrag);
}
updateRangeFromCheckbox(dropdown, column) {
const minInput = dropdown.querySelector('.range-min');
const maxInput = dropdown.querySelector('.range-max');
const slider = dropdown.querySelector('.range-slider');
if (!minInput || !maxInput || !slider) return;
const checkedCheckboxes = dropdown.querySelectorAll('.filter-checkbox:not([data-value="__all__"]):checked');
// Update "Select All" checkbox state
const selectAllCheckbox = dropdown.querySelector('[data-value="__all__"]');
if (selectAllCheckbox) {
const totalCheckboxes = dropdown.querySelectorAll('.filter-checkbox:not([data-value="__all__"])');
TableUtils.updateSelectAllState(selectAllCheckbox, totalCheckboxes);
}
// Handle slider update based on checkbox selection
if (checkedCheckboxes.length === 0) {
// No checkboxes selected - reset to full range
const fullMin = parseFloat(slider.dataset.min);
const fullMax = parseFloat(slider.dataset.max);
// Set flag to prevent infinite loop
slider._isUpdatingFromSlider = true;
minInput.value = fullMin;
maxInput.value = fullMax;
// Update slider display to full range
const track = dropdown.querySelector('.range-track');
const minThumb = dropdown.querySelector('.range-thumb-min');
const maxThumb = dropdown.querySelector('.range-thumb-max');
if (track && minThumb && maxThumb) {
minThumb.style.left = '0%';
maxThumb.style.left = '100%';
track.style.left = '0%';
track.style.width = '100%';
}
// Reset flag
setTimeout(() => {
slider._isUpdatingFromSlider = false;
}, 0);
} else {
// Some checkboxes selected - update slider only if boundary values changed
const checkedValues = Array.from(checkedCheckboxes).map(cb => parseFloat(cb.dataset.value));
const currentMin = Math.min(...checkedValues);
const currentMax = Math.max(...checkedValues);
const sliderMin = parseFloat(minInput.value);
const sliderMax = parseFloat(maxInput.value);
// Only update slider if the min or max boundary values have changed
if (currentMin !== sliderMin || currentMax !== sliderMax) {
// Set flag to prevent infinite loop
slider._isUpdatingFromSlider = true;
minInput.value = currentMin;
maxInput.value = currentMax;
// Update slider display
const min = parseFloat(slider.dataset.min);
const max = parseFloat(slider.dataset.max);
const range = max - min;
const minPercent = ((currentMin - min) / range) * 100;
const maxPercent = ((currentMax - min) / range) * 100;
const track = dropdown.querySelector('.range-track');
const minThumb = dropdown.querySelector('.range-thumb-min');
const maxThumb = dropdown.querySelector('.range-thumb-max');
if (track && minThumb && maxThumb) {
minThumb.style.left = `${minPercent}%`;
maxThumb.style.left = `${maxPercent}%`;
track.style.left = `${minPercent}%`;
track.style.width = `${maxPercent - minPercent}%`;
}
// Reset flag
setTimeout(() => {
slider._isUpdatingFromSlider = false;
}, 0);
}
}
}
updateCheckboxFromRange(dropdown, column) {
const minInput = dropdown.querySelector('.range-min');
const maxInput = dropdown.querySelector('.range-max');
if (!minInput || !maxInput) return;
const minVal = parseFloat(minInput.value);
const maxVal = parseFloat(maxInput.value);
const checkboxes = dropdown.querySelectorAll('.filter-checkbox:not([data-value="__all__"])');
checkboxes.forEach(cb => {
const value = parseFloat(cb.dataset.value);
if (!isNaN(value)) {
cb.checked = value >= minVal && value <= maxVal;
}
});
// Update select all checkbox state
const selectAllCheckbox = dropdown.querySelector('[data-value="__all__"]');
if (selectAllCheckbox) {
const checkedCount = dropdown.querySelectorAll('.filter-checkbox:not([data-value="__all__"]):checked').length;
const totalCount = dropdown.querySelectorAll('.filter-checkbox:not([data-value="__all__"])').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;
}
}
}
attachDateRangeSliderEvents(dropdown, column, applyRealtimeFilter) {
const slider = dropdown.querySelector('.date-range-slider');
const track = dropdown.querySelector('.date-range-track');
const minThumb = dropdown.querySelector('.date-range-thumb-min');
const maxThumb = dropdown.querySelector('.date-range-thumb-max');
const minInput = dropdown.querySelector('.date-range-min');
const maxInput = dropdown.querySelector('.date-range-max');
if (!slider || !track || !minThumb || !maxThumb || !minInput || !maxInput) return;
const minDate = new Date(slider.dataset.min).getTime();
const maxDate = new Date(slider.dataset.max).getTime();
const range = maxDate - minDate;
const updateSlider = () => {
const minVal = new Date(minInput.value).getTime();
const maxVal = new Date(maxInput.value).getTime();
const minPercent = ((minVal - minDate) / range) * 100;
const maxPercent = ((maxVal - minDate) / range) * 100;
minThumb.style.left = `${minPercent}%`;
maxThumb.style.left = `${maxPercent}%`;
track.style.left = `${minPercent}%`;
track.style.width = `${maxPercent - minPercent}%`;
};
// Set slider range based on currently selected checkboxes
this.updateDateSliderFromCheckboxes(dropdown, column);
// Initial update
updateSlider();
// Input change events
minInput.addEventListener('input', () => {
updateSlider();
this.updateDateCheckboxFromRange(dropdown, column);
// Apply real-time filtering (no icon state update during real-time)
applyRealtimeFilter();
});
maxInput.addEventListener('input', () => {
updateSlider();
this.updateDateCheckboxFromRange(dropdown, column);
// Apply real-time filtering (no icon state update during real-time)
applyRealtimeFilter();
});
// Thumb drag events
let isDragging = false;
let currentThumb = null;
const startDrag = (e, thumb) => {
isDragging = true;
currentThumb = thumb;
e.preventDefault();
};
const drag = (e) => {
if (!isDragging || !currentThumb) return;
const rect = slider.getBoundingClientRect();
// Handle both mouse and touch events
const clientX = e.clientX || (e.touches && e.touches[0] ? e.touches[0].clientX : 0);
const x = clientX - rect.left;
const percent = Math.max(0, Math.min(100, (x / rect.width) * 100));
const timestamp = minDate + (percent / 100) * range;
const dateValue = new Date(timestamp).toISOString().split('T')[0];
if (currentThumb.dataset.type === 'min') {
const maxVal = maxInput.value;
const newMinVal = dateValue <= maxVal ? dateValue : maxVal;
minInput.value = newMinVal;
} else {
const minVal = minInput.value;
const newMaxVal = dateValue >= minVal ? dateValue : minVal;
maxInput.value = newMaxVal;
}
updateSlider();
this.updateDateCheckboxFromRange(dropdown, column);
// Apply real-time filtering (no icon state update during real-time)
applyRealtimeFilter();
};
const stopDrag = () => {
isDragging = false;
currentThumb = null;
};
// Mouse events
minThumb.addEventListener('mousedown', (e) => startDrag(e, minThumb));
maxThumb.addEventListener('mousedown', (e) => startDrag(e, maxThumb));
document.addEventListener('mousemove', drag);
document.addEventListener('mouseup', stopDrag);
// Touch events for mobile support
minThumb.addEventListener('touchstart', (e) => startDrag(e, minThumb), { passive: false });
maxThumb.addEventListener('touchstart', (e) => startDrag(e, maxThumb), { passive: false });
document.addEventListener('touchmove', drag, { passive: false });
document.addEventListener('touchend', stopDrag);
}
// Duplicate method removed - keeping the first implementation with parent checkbox updates
attachDateHierarchyEvents(dropdown, column, applyRealtimeFilter) {
// Expand level control buttons
const expandControlButtons = dropdown.querySelectorAll('.expand-control-btn');
const dateFilterContent = dropdown.querySelector('.date-filter-content');
const setExpandLevel = (level) => {
const yearGroups = dropdown.querySelectorAll('.date-filter-group');
const monthItems = dropdown.querySelectorAll('.date-filter-month');
const dayItems = dropdown.querySelectorAll('.date-filter-day');
// Reset active button
expandControlButtons.forEach(btn => btn.classList.remove('active'));
const activeButton = dropdown.querySelector(`[data-level="${level}"]`);
if (activeButton) {
activeButton.classList.add('active');
}
switch(level) {
case 'year':
// Show only years, hide months and days
yearGroups.forEach(group => {
group.style.display = 'block';
const children = group.querySelector('.date-filter-children');
children.style.display = 'none';
const expandBtn = group.querySelector('.expand-btn');
expandBtn.textContent = '▶';
});
break;
case 'month':
// Show years and months, hide days
yearGroups.forEach(group => {
group.style.display = 'block';
const children = group.querySelector('.date-filter-children');
children.style.display = 'block';
const expandBtn = group.querySelector('.expand-btn');
expandBtn.textContent = '▼';
});
monthItems.forEach(month => {
month.style.display = 'block';
const days = month.querySelector('.date-filter-days');
days.style.display = 'none';
const expandBtn = month.querySelector('.expand-btn-month');
expandBtn.textContent = '▶';
});
break;
case 'day':
// Show everything - years, months, and days
yearGroups.forEach(group => {
group.style.display = 'block';
const children = group.querySelector('.date-filter-children');
children.style.display = 'block';
const expandBtn = group.querySelector('.expand-btn');
expandBtn.textContent = '▼';
});
monthItems.forEach(month => {
month.style.display = 'block';
const days = month.querySelector('.date-filter-days');
days.style.display = 'block';
const expandBtn = month.querySelector('.expand-btn-month');
expandBtn.textContent = '▼';
});
dayItems.forEach(day => {
day.style.display = 'block';
});
break;
}
};
// Add click handlers for expand control buttons
expandControlButtons.forEach(btn => {
btn.addEventListener('click', (e) => {
e.preventDefault();
e.stopPropagation();
setExpandLevel(btn.dataset.level);
});
});
// Set initial expand level to 'year'
setExpandLevel('year');
// Expand/collapse for years
const yearExpandButtons = dropdown.querySelectorAll('.expand-btn:not(.expand-btn-month)');
yearExpandButtons.forEach(btn => {
btn.addEventListener('click', (e) => {
e.stopPropagation();
const group = btn.closest('.date-filter-group');
const children = group.querySelector('.date-filter-children');
if (children.style.display === 'none' || !children.style.display) {
children.style.display = 'block';
btn.textContent = '▼';
} else {
children.style.display = 'none';
btn.textContent = '▶';
}
});
});
// Expand/collapse for months
const monthExpandButtons = dropdown.querySelectorAll('.expand-btn-month');
monthExpandButtons.forEach(btn => {
btn.addEventListener('click', (e) => {
e.stopPropagation();
const monthItem = btn.closest('.date-filter-month');
const days = monthItem.querySelector('.date-filter-days');
if (days.style.display === 'none' || !days.style.display) {
days.style.display = 'block';
btn.textContent = '▼';
} else {
days.style.display = 'none';
btn.textContent = '▶';
}
});
});
// Use the centralized updateDateParentCheckboxes method instead of local function
// 日付階層チェックボックスの統一イベントハンドラー
// リアルタイムフィルタフラグを初期化
if (this._applyRealtimeOnDateChange === undefined) {
this._applyRealtimeOnDateChange = true;
}
const hierarchyCheckboxes = dropdown.querySelectorAll('.date-filter-checkbox');
hierarchyCheckboxes.forEach(checkbox => {
checkbox.addEventListener('change', (e) => {
// リアルタイムフィルタリングを一時的に無効化
const originalApplyRealtime = this._applyRealtimeOnDateChange;
this._applyRealtimeOnDateChange = false;
// 共通の階層処理を実行
this.handleDateHierarchyCheckboxChange(e, dropdown, () => {
// 階層処理完了後にリアルタイムフィルタを適用
this._applyRealtimeOnDateChange = originalApplyRealtime;
if (this._applyRealtimeOnDateChange && applyRealtimeFilter) {
applyRealtimeFilter();
}
});
});
});
}
handleRangeFilter(columnKey, min, max) {
const column = this.options.columns.find(col => col.key === columnKey);
if (!column) return;
// Apply range filter
this.state.filteredData = this.state.data.filter(record => {
const value = parseFloat(record[column.dataIndex]);
return !isNaN(value) && value >= min && value <= max;
});
this.state.currentPage = 1;
this.applySorting();
this.renderBody();
this.renderPagination();
}
isYearSelected(year, monthData, currentFilters) {
if (currentFilters.length === 0) return true;
// Check if year itself is selected
if (currentFilters.includes(year)) return true;
// Check if any month in this year is selected
if (Object.keys(monthData).some(month =>
currentFilters.includes(`${year}-${month}`)
)) return true;
// Check if any individual dates in this year are selected
return currentFilters.some(filter => {
if (typeof filter === 'string' && filter.includes('-')) {
return filter.startsWith(`${year}-`);
}
return false;
});
}
isMonthSelected(year, month, dates, currentFilters) {
if (currentFilters.length === 0) return true;
// Check if year is selected (includes all months)
if (currentFilters.includes(year)) return true;
// Check if this specific month is selected
return currentFilters.includes(`${year}-${month}`);
}
// Utility methods
isNumericColumn(column) {
const sampleValue = this.state.data[0]?.[column.dataIndex];
return typeof sampleValue === 'number' || !isNaN(Number(sampleValue));
}
groupDatesByHierarchy(dates) {
const groups = {};
dates.forEach(dateStr => {
if (dateStr) {
const date = new Date(dateStr);
const year = date.getFullYear();
const monthNum = String(date.getMonth() + 1).padStart(2, '0'); // Use numeric month for sorting
const month = date.toLocaleString('default', { month: 'long' });
if (!groups[year]) groups[year] = {};
if (!groups[year][monthNum]) groups[year][monthNum] = { displayName: month, dates: [] };
groups[year][monthNum].dates.push(dateStr);
}
});
// Sort years, months, and dates in ascending order
const sortedGroups = {};
const sortedYears = Object.keys(groups).sort((a, b) => parseInt(a) - parseInt(b));
sortedYears.forEach(year => {
sortedGroups[year] = {};
// Sort months numerically (01, 02, 03, ...)
const monthNums = Object.keys(groups[year]).sort((a, b) => parseInt(a) - parseInt(b));
monthNums.forEach(monthNum => {
const monthData = groups[year][monthNum];
const displayName = monthData.displayName;
// Sort dates within each month in ascending order
sortedGroups[year][displayName] = monthData.dates.sort((a, b) => new Date(a) - new Date(b));
});
});
return sortedGroups;
}
// Public API methods
setData(data) {
this.state.data = [...data];
this.state.currentPage = 1;
this.render();
}
updateData(data) {
this.setData(data);
}
getFilters() {
return { ...this.state.filters };
}
setFilters(filters) {
this.state.filters = { ...filters };
this.render();
}
clearFilters() {
this.state.filters = {};
this.render();
}
clearAllFilters() {
this.state.filters = {};
this.state.rangeFilters = {};
this.state.dateRangeFilters = {};
this.render();
this.autoSaveSettings();
}
setFontSize(size) {
this.state.fontSize = size;
this.renderMenu(); // Re-render menu to update current selection
this.applyCurrentStyling(); // Apply updated styling
this.autoSaveSettings();
}
setCellPadding(padding) {
this.state.cellPadding = padding;
this.renderMenu(); // Re-render menu to update current selection
this.applyCurrentStyling(); // Apply updated styling
this.autoSaveSettings();
}
applyFontSize() {
const fontSize = TABLE_CONFIG.FONT_SIZES[this.state.fontSize] || TABLE_CONFIG.FONT_SIZES.medium;
// Use CSS custom property for consistent font size application
this.container.style.setProperty('--table-font-size', fontSize);
// Guard clause: return early if structure not created yet
if (!this.tableContainer) {
return;
}
// Apply directly to main elements with !important to override CSS
this.tableContainer.style.setProperty('font-size', fontSize, 'important');
// Apply to table elements
const tableElements = this.tableContainer.querySelectorAll(`
.table,
.table-cell,
.table-header,
.header-content,
.header-title,
.enhanced-table-pagination,
.pagination-info,
.page-info,
tbody tr td
`);
tableElements.forEach(element => {
element.style.setProperty('font-size', fontSize, 'important');
});
// Apply to menu elements
const menuElements = this.container.querySelectorAll(`
.table-menu-container,
.table-menu-btn,
.table-menu-dropdown,
.table-menu-item,
.table-submenu-dropdown
`);
menuElements.forEach(element => {
element.style.setProperty('font-size', fontSize, 'important');
});
// Apply to filter elements
const filterElements = document.querySelectorAll(`
.filter-dropdown-wrapper,
.filter-dropdown,
.filter-dropdown-header,
.filter-dropdown-title,
.filter-dropdown-search,
.filter-search-input,
.filter-dropdown-content,
.filter-option,
.filter-option-label,
.filter-option-text,
.filter-dropdown-footer,
.filter-btn-confirm,
.filter-btn-cancel,
.filter-btn-clear-column,
.range-filter,
.range-label,
.range-input,
.date-range-filter,
.date-range-label,
.date-range-input,
.date-filter-content,
.date-filter-label,
.date-filter-text,
.date-expand-controls,
.expand-control-label,
.expand-control-btn
`);
filterElements.forEach(element => {
element.style.setProperty('font-size', fontSize, 'important');
});
// Apply to column settings modal
const modalElements = document.querySelectorAll(`
.column-settings-overlay,
.column-settings-modal,
.column-settings-header,
.column-settings-content,
.column-list,
.column-item,
.column-label,
.column-text,
.column-settings-footer,
.confirm-btn,
.cancel-btn
`);
modalElements.forEach(element => {
element.style.setProperty('font-size', fontSize, 'important');
});
}
applyCellPadding() {
const padding = TABLE_CONFIG.CELL_PADDING[this.state.cellPadding] || TABLE_CONFIG.CELL_PADDING.standard;
const cellPaddingValue = `${padding.vertical} ${padding.horizontal}`;
// Guard clause: return early if structure not created yet
if (!this.tableContainer) {
return;
}
// Smaller padding for UI elements (50% of cell padding)
const uiVertical = Math.round(parseInt(padding.vertical) * 0.6) + 'px';
const uiHorizontal = Math.round(parseInt(padding.horizontal) * 0.8) + 'px';
const uiPaddingValue = `${uiVertical} ${uiHorizontal}`;
// Apply padding to table cells
const cells = this.tableContainer.querySelectorAll('.table-cell');
cells.forEach(cell => {
cell.style.setProperty('padding', cellPaddingValue, 'important');
});
// Apply padding to header cells
const headers = this.tableContainer.querySelectorAll('.header-content');
headers.forEach(header => {
header.style.setProperty('padding', cellPaddingValue, 'important');
});
// Apply padding to menu elements
const menuElements = this.container.querySelectorAll(`
.table-menu-item,
.table-menu-btn
`);
menuElements.forEach(element => {
element.style.setProperty('padding', uiPaddingValue, 'important');
});
// Apply padding to filter elements
const filterElements = document.querySelectorAll(`
.filter-option,
.filter-btn-confirm,
.filter-btn-cancel,
.filter-btn-clear-column,
.range-input,
.date-range-input,
.filter-search-input,
.expand-control-btn
`);
filterElements.forEach(element => {
element.style.setProperty('padding', uiPaddingValue, 'important');
});
// Apply padding to larger filter sections
const filterSections = document.querySelectorAll(`
.filter-dropdown-header,
.filter-dropdown-search,
.filter-dropdown-footer,
.date-expand-controls
`);
filterSections.forEach(element => {
element.style.setProperty('padding', cellPaddingValue, 'important');
});
// Apply padding to pagination elements
const paginationElements = this.tableContainer.querySelectorAll(`
.enhanced-table-pagination,
.pagination-controls button
`);
paginationElements.forEach(element => {
element.style.setProperty('padding', uiPaddingValue, 'important');
});
// Apply padding to column settings modal
const modalElements = document.querySelectorAll(`
.column-item,
.confirm-btn,
.cancel-btn
`);
modalElements.forEach(element => {
element.style.setProperty('padding', uiPaddingValue, 'important');
});
}
getSelectedRows() {
// Implementation for row selection if needed
return [];
}
addTooltipToCell(element, content) {
if (!element || !content) return;
// Convert content to string if it's not already
const text = typeof content === 'string' ? content : String(content);
let tooltip = null;
const showTooltip = (e) => {
// Check if content is actually truncated by comparing scrollWidth with clientWidth
if (element.scrollWidth <= element.clientWidth && element.scrollHeight <= element.clientHeight) {
return; // No overflow, no need for tooltip
}
// Create tooltip
tooltip = document.createElement('div');
tooltip.className = 'cell-tooltip';
tooltip.textContent = text;
document.body.appendChild(tooltip);
// Position tooltip
const rect = element.getBoundingClientRect();
const tooltipRect = tooltip.getBoundingClientRect();
let left = rect.left + window.scrollX;
let top = rect.bottom + window.scrollY + 5;
// Adjust if tooltip would go outside viewport
if (left + tooltipRect.width > window.innerWidth) {
left = window.innerWidth - tooltipRect.width - 10;
}
if (left < 10) {
left = 10;
}
if (top + tooltipRect.height > window.innerHeight + window.scrollY) {
top = rect.top + window.scrollY - tooltipRect.height - 5;
}
tooltip.style.left = `${left}px`;
tooltip.style.top = `${top}px`;
};
const hideTooltip = () => {
if (tooltip) {
document.body.removeChild(tooltip);
tooltip = null;
}
};
// Add event listeners
element.addEventListener('mouseenter', showTooltip);
element.addEventListener('mouseleave', hideTooltip);
// Store cleanup function for potential future use
element._tooltipCleanup = () => {
element.removeEventListener('mouseenter', showTooltip);
element.removeEventListener('mouseleave', hideTooltip);
hideTooltip();
};
}
/**
* Show confirmation modal for LocalStorage clear
*/
showLocalStorageClearConfirmation() {
// Create modal overlay
const modalOverlay = document.createElement('div');
modalOverlay.className = 'modal-overlay';
modalOverlay.style.cssText = `
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.5);
display: flex;
justify-content: center;
align-items: center;
z-index: 10000;
`;
// Create modal content
const modalContent = document.createElement('div');
modalContent.className = 'modal-content';
modalContent.style.cssText = `
background: white;
padding: 24px;
border-radius: 8px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
max-width: 400px;
margin: 20px;
`;
modalContent.innerHTML = `
<h3 style="margin: 0 0 16px 0; color: #333;">LocalStorage クリア確認</h3>
<p style="margin: 0 0 20px 0; line-height: 1.5; color: #666;">
ExceLikeTable関連のLocalStorage情報を削除し、設定をデフォルトに戻します。よろしいですか?
</p>
<div style="display: flex; gap: 12px; justify-content: flex-end;">
<button class="modal-cancel-btn" style="
padding: 8px 16px;
border: 1px solid #ddd;
background: white;
border-radius: 4px;
cursor: pointer;
">キャンセル</button>
<button class="modal-confirm-btn" style="
padding: 8px 16px;
border: none;
background: #ff4d4f;
color: white;
border-radius: 4px;
cursor: pointer;
">削除する</button>
</div>
`;
modalOverlay.appendChild(modalContent);
document.body.appendChild(modalOverlay);
// Event handlers
const closeModal = () => {
document.body.removeChild(modalOverlay);
};
const cancelBtn = modalContent.querySelector('.modal-cancel-btn');
const confirmBtn = modalContent.querySelector('.modal-confirm-btn');
cancelBtn.addEventListener('click', closeModal);
confirmBtn.addEventListener('click', async () => {
await this.clearLocalStorageData();
closeModal();
});
// Close on overlay click
modalOverlay.addEventListener('click', (e) => {
if (e.target === modalOverlay) {
closeModal();
}
});
// Close on Escape key
const handleEscape = (e) => {
if (e.key === 'Escape') {
closeModal();
document.removeEventListener('keydown', handleEscape);
}
};
document.addEventListener('keydown', handleEscape);
}
/**
* Clear LocalStorage data and reset to defaults
*/
async clearLocalStorageData() {
// Prevent recursive calls
if (this._clearingLocalStorage) {
return;
}
this._clearingLocalStorage = true;
try {
// Clear this table's settings
if (this.settingsManager) {
await this.clearSettings();
}
// Clear all ExceLikeTable related LocalStorage data
Object.keys(localStorage).forEach(key => {
if (key.startsWith('excelike_')) {
localStorage.removeItem(key);
}
});
// Close any open filters
this.closeFilter();
// Reset table state to defaults
this.state.columnWidths = {};
this.state.visibleColumns = {};
this.state.pinnedColumns = {};
this.state.filters = {};
this.state.sortState = {};
this.state.rangeFilters = {};
this.state.dateRangeFilters = {};
this.state.fontSize = 'medium';
this.state.cellPadding = 'standard';
this.state.currentPage = 1;
this.state.openFilter = null;
// Also reset any cached filter states
if (this.state.columnFilters) {
this.state.columnFilters = {};
}
// Reset filtered and sorted data to original data
this.state.filteredData = [...this.state.data];
this.state.sortedData = [...this.state.data];
// Show success message and auto-reload
this.showTemporaryMessage('設定をクリアしました。ページが自動で再読み込みされます。', 'success');
// Auto-reload page after 0.5 seconds to ensure clean state
setTimeout(() => {
window.location.reload();
}, 500);
} catch (error) {
console.error('Error clearing LocalStorage data:', error);
// Emergency fallback - at least show an error message
try {
this.showTemporaryMessage('設定クリア中にエラーが発生しました', 'error');
} catch (msgError) {
console.error('Could not show error message:', msgError);
}
} finally {
// Always reset the flag
this._clearingLocalStorage = false;
}
}
/**
* Apply default visual settings immediately
*/
applyDefaultVisualSettings() {
// Guard clause: ensure table container exists
if (!this.tableContainer) {
console.warn('Table container not found, skipping visual settings reset');
return;
}
// Reset state only without triggering render methods
this.state.fontSize = 'medium';
this.state.cellPadding = 'standard';
// Apply styling directly to avoid render side effects
this.applyCurrentStyling();
// Reset all column widths by removing style attributes
const headers = this.tableContainer.querySelectorAll('th');
headers.forEach(header => {
header.style.width = '';
header.style.minWidth = '';
header.style.maxWidth = '';
});
// Reset table cells width
const cells = this.tableContainer.querySelectorAll('td');
cells.forEach(cell => {
cell.style.width = '';
cell.style.minWidth = '';
cell.style.maxWidth = '';
});
// Remove any custom CSS classes or styles that might have been applied
const table = this.tableContainer.querySelector('.table');
if (table) {
table.style.fontSize = '';
table.classList.remove('font-smallest', 'font-small', 'font-large', 'font-largest');
table.classList.remove('padding-wide', 'padding-narrow');
}
// Reset any pinned column styles
const pinnedCells = this.tableContainer.querySelectorAll('.pinned-left');
pinnedCells.forEach(cell => {
cell.classList.remove('pinned-left');
cell.style.position = '';
cell.style.left = '';
cell.style.zIndex = '';
cell.style.backgroundColor = '';
});
}
/**
* Show temporary message to user
*/
showTemporaryMessage(message, type = 'info') {
const messageDiv = document.createElement('div');
messageDiv.style.cssText = `
position: fixed;
top: 20px;
right: 20px;
padding: 12px 20px;
border-radius: 4px;
color: white;
font-size: 14px;
z-index: 10001;
animation: slideIn 0.3s ease-out;
background: ${type === 'success' ? '#52c41a' : type === 'error' ? '#ff4d4f' : '#1890ff'};
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
`;
// Add animation styles
const style = document.createElement('style');
style.textContent = `
@keyframes slideIn {
from { transform: translateX(100%); opacity: 0; }
to { transform: translateX(0); opacity: 1; }
}
@keyframes slideOut {
from { transform: translateX(0); opacity: 1; }
to { transform: translateX(100%); opacity: 0; }
}
`;
document.head.appendChild(style);
messageDiv.textContent = message;
document.body.appendChild(messageDiv);
// Auto remove after 3 seconds
setTimeout(() => {
messageDiv.style.animation = 'slideOut 0.3s ease-out';
setTimeout(() => {
if (messageDiv.parentNode) {
document.body.removeChild(messageDiv);
}
if (style.parentNode) {
document.head.removeChild(style);
}
}, 300);
}, 3000);
}
destroy() {
// Clean up event listeners and DOM
this.container.innerHTML = '';
this.closeFilter();
}
}
// Export for different module systems
if (typeof module !== 'undefined' && module.exports) {
module.exports = ExceLikeTable;
}
if (typeof window !== 'undefined') {
window.ExceLikeTable = ExceLikeTable;
}
// ESM Export
export { ExceLikeTable as default, ColumnHelpers, TableUtils, TABLE_PRESETS, TABLE_CONFIG };