UNPKG

modern-table-js

Version:

Modern, lightweight, vanilla JavaScript table library with zero dependencies. 67% faster than DataTables with mobile-first responsive design.

1,472 lines (1,272 loc) 231 kB
(function (global, factory) { typeof exports === 'object' && typeof module !== 'undefined' ? factory(exports) : typeof define === 'function' && define.amd ? define(['exports'], factory) : (global = typeof globalThis !== 'undefined' ? globalThis : global || self, factory(global.ModernTable = {})); })(this, (function (exports) { 'use strict'; /** * EventEmitter - Modern event system for ModernTable.js */ class EventEmitter { constructor() { this.events = new Map(); } /** * Add event listener */ on(event, callback) { if (!this.events.has(event)) { this.events.set(event, []); } this.events.get(event).push(callback); return this; } /** * Remove event listener */ off(event, callback) { if (!this.events.has(event)) return this; const callbacks = this.events.get(event); const index = callbacks.indexOf(callback); if (index > -1) { callbacks.splice(index, 1); } return this; } /** * Emit event */ emit(event, ...args) { if (!this.events.has(event)) return this; this.events.get(event).forEach(callback => { try { callback(...args); } catch (error) { console.error(`Error in event listener for '${event}':`, error); } }); return this; } /** * Add one-time event listener */ once(event, callback) { const onceCallback = (...args) => { callback(...args); this.off(event, onceCallback); }; return this.on(event, onceCallback); } /** * Remove all listeners for event */ removeAllListeners(event) { if (event) { this.events.delete(event); } else { this.events.clear(); } return this; } } /** * ApiClient - Modern fetch-based HTTP client for ModernTable.js */ class ApiClient { constructor(config = {}) { // Handle string URL or object config if (typeof config === 'string') { this.config = { url: config, method: 'GET', headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' } }; } else { this.config = { url: null, method: 'GET', headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' }, ...config }; } } /** * Make HTTP request with jQuery.ajax-like callbacks */ async request(params = {}) { let config; try { // beforeSend callback if (this.config.beforeSend) { const shouldContinue = await this.config.beforeSend(params); if (shouldContinue === false) { return; // Abort request } } config = await this.prepareRequest(params); // Setup timeout if specified const controller = new AbortController(); let timeoutId; if (this.config.timeout) { timeoutId = setTimeout(() => { controller.abort(); }, this.config.timeout); } const response = await fetch(config.url, { method: config.method, headers: config.headers, body: config.method !== 'GET' ? JSON.stringify(config.data) : null, signal: controller.signal }); // Clear timeout if (timeoutId) { clearTimeout(timeoutId); } if (!response.ok) { throw new Error(`HTTP ${response.status}: ${response.statusText}`); } let data = await response.json(); // DataTables-compatible dataSrc transformation if (this.config.dataSrc && typeof this.config.dataSrc === 'function') { console.log('🔄 Applying dataSrc transformation'); data = this.config.dataSrc(data); } // success callback if (this.config.success) { await this.config.success(data, 'success', response); } return data; } catch (error) { // error callback if (this.config.error) { const result = await this.config.error(error, 'error', error.message); if (result) return result; // Allow error callback to provide fallback data } // Legacy onError support if (this.config.onError) { return await this.config.onError(error); } throw error; } finally { // complete callback (always runs) if (this.config.complete) { await this.config.complete(); } } } /** * Prepare request configuration */ async prepareRequest(params) { let config = { url: this.buildUrl(params), method: this.config.method || 'GET', headers: { ...this.config.headers }, data: this.config.data ? this.config.data(params) : params }; // Add CSRF token if available const csrfToken = document.querySelector('meta[name="csrf-token"]')?.content; if (csrfToken) { config.headers['X-CSRF-TOKEN'] = csrfToken; } // Apply request interceptor if (this.config.beforeRequest) { config = await this.config.beforeRequest(config); } return config; } /** * Build URL with query parameters */ buildUrl(params) { const baseUrl = typeof this.config === 'string' ? this.config : this.config.url; if (!baseUrl) { throw new Error('API URL is not configured'); } if (this.config.method === 'GET' && params && Object.keys(params).length > 0) { const url = new URL(baseUrl, window.location.origin); Object.entries(params).forEach(([key, value]) => { if (value !== null && value !== undefined) { if (Array.isArray(value)) { value.forEach((item, index) => { if (typeof item === 'object' && item !== null) { Object.entries(item).forEach(([subKey, subValue]) => { if (typeof subValue === 'object' && subValue !== null) { // Handle nested objects (like search: {value: 'x', regex: false}) Object.entries(subValue).forEach(([nestedKey, nestedValue]) => { url.searchParams.append(`${key}[${index}][${subKey}][${nestedKey}]`, nestedValue); }); } else { url.searchParams.append(`${key}[${index}][${subKey}]`, subValue); } }); } else { url.searchParams.append(`${key}[${index}]`, item); } }); } else if (typeof value === 'object' && value !== null) { Object.entries(value).forEach(([subKey, subValue]) => { if (typeof subValue === 'object' && subValue !== null) { Object.entries(subValue).forEach(([nestedKey, nestedValue]) => { url.searchParams.append(`${key}[${subKey}][${nestedKey}]`, nestedValue); }); } else { url.searchParams.append(`${key}[${subKey}]`, subValue); } }); } else { url.searchParams.append(key, value); } } }); return url.toString(); } return baseUrl; } /** * GET request */ async get(params) { const originalMethod = this.config.method; this.config.method = 'GET'; const result = await this.request(params); this.config.method = originalMethod; return result; } /** * POST request */ async post(data) { const originalMethod = this.config.method; this.config.method = 'POST'; const result = await this.request(data); this.config.method = originalMethod; return result; } /** * PUT request */ async put(data) { const originalMethod = this.config.method; this.config.method = 'PUT'; const result = await this.request(data); this.config.method = originalMethod; return result; } /** * DELETE request */ async delete(data) { const originalMethod = this.config.method; this.config.method = 'DELETE'; const result = await this.request(data); this.config.method = originalMethod; return result; } } /** * StateManager.js - State persistence for ModernTable.js * Sesuai master plan: core/StateManager.js (1KB) */ class StateManager { constructor(table) { this.table = table; // Generate unique key based on element ID or create one const tableId = this.table.element.id || `table_${Date.now()}`; this.storageKey = `modernTable_${tableId}`; this.duration = (this.table.options.stateDuration || 7200) * 1000; // Convert to milliseconds } /** * Save current table state */ save() { if (!this.table.options.stateSave) return; const filters = this.table.components.filterPanel?.getFilters() || {}; const state = { page: this.table.currentPage, pageLength: this.table.options.pageLength, search: this.table.searchInput?.value?.trim() || '', order: this.table.plugins.sorting?.getCurrentSort() || null, filters: filters, columns: this.getColumnStates(), selection: this.table.plugins.selection?.getSelectedRowIds() || [], timestamp: Date.now() }; try { localStorage.setItem(this.storageKey, JSON.stringify(state)); } catch (error) { console.warn('Failed to save table state:', error); } } /** * Load saved table state */ load() { if (!this.table.options.stateSave) return null; try { const saved = localStorage.getItem(this.storageKey); if (!saved) return null; const state = JSON.parse(saved); // Check if state is expired if (Date.now() - state.timestamp > this.duration) { this.clear(); return null; } return state; } catch (error) { console.warn('Failed to load table state:', error); return null; } } /** * Apply saved state to table (async with delays) */ apply(state) { if (!state) return; // Use setTimeout to ensure DOM is ready setTimeout(() => { this.applyStateWhenReady(state); }, 100); } /** * Apply saved state synchronously (for initial load) */ applySync(state) { if (!state) return; try { // Apply page length immediately if (state.pageLength) { this.table.options.pageLength = state.pageLength; } // Apply search immediately if (state.search) { // Store search term for when input is ready this.pendingSearch = state.search; } // Apply sorting immediately if (state.order) { // Store sort for when plugin is ready this.pendingSort = state.order; } // Apply filters immediately if (state.filters) { // Store filters for when FilterPanel is ready this.pendingFilters = state.filters; } // Apply column visibility immediately if (state.columns) { this.pendingColumns = state.columns; } // Apply selection immediately if (state.selection) { this.pendingSelection = state.selection; } // Apply page immediately if (state.page) { this.table.currentPage = state.page; } // Apply pending states after components are ready setTimeout(() => { this.applyPendingStates(); }, 50); } catch (error) { console.warn('Error applying state sync:', error); } } /** * Apply pending states after components are initialized */ applyPendingStates() { try { // Apply pending search if (this.pendingSearch && this.table.searchInput) { this.table.searchInput.value = this.pendingSearch; // Trigger input event to show clear button this.table.searchInput.dispatchEvent(new Event('input')); // Don't clear pendingSearch immediately, keep it for buildRequestParams setTimeout(() => { this.pendingSearch = null; }, 100); } // Apply pending sort if (this.pendingSort && this.table.plugins?.sorting) { if (typeof this.table.plugins.sorting.setSort === 'function') { this.table.plugins.sorting.setSort(this.pendingSort.column, this.pendingSort.dir); } else { this.table.plugins.sorting.currentSort = this.pendingSort; } this.pendingSort = null; } // Apply length select after it's created if (this.table.options.pageLength && this.table.lengthSelect) { this.table.lengthSelect.value = this.table.options.pageLength; } // Apply pending filters using direct method if (this.pendingFilters && this.table.components?.filterPanel) { if (typeof this.table.components.filterPanel.setFilters === 'function') { this.table.components.filterPanel.setFilters(this.pendingFilters); } else { this.applyFilters(this.pendingFilters); } this.pendingFilters = null; } // Apply pending column visibility if (this.pendingColumns) { this.applyColumnStates(this.pendingColumns); this.pendingColumns = null; } // Apply pending selection after a longer delay to ensure data is rendered if (this.pendingSelection && this.table.plugins?.selection) { setTimeout(() => { this.table.plugins.selection.restoreSelection(this.pendingSelection); this.pendingSelection = null; }, 300); } } catch (error) { console.warn('Error applying pending states:', error); } } /** * Apply state when DOM elements are ready */ applyStateWhenReady(state) { try { // Apply page length FIRST if (state.pageLength && this.table.lengthSelect) { this.table.lengthSelect.value = state.pageLength; this.table.options.pageLength = state.pageLength; } // Apply search if (state.search && this.table.searchInput) { this.table.searchInput.value = state.search; } // Apply sorting with error handling if (state.order && this.table.plugins?.sorting) { if (typeof this.table.plugins.sorting.setSort === 'function') { this.table.plugins.sorting.setSort(state.order.column, state.order.dir); } else { // Fallback: set currentSort directly this.table.plugins.sorting.currentSort = state.order; } } // Apply filters with delay for FilterPanel if (state.filters && this.table.components?.filterPanel) { setTimeout(() => { this.applyFilters(state.filters); }, 200); } // Apply column states if (state.columns) { this.applyColumnStates(state.columns); } // Apply page (after other states) if (state.page) { this.table.currentPage = state.page; } } catch (error) { console.warn('Error applying state:', error); } } /** * Get current column states */ getColumnStates() { // Use columnVisibility state if available, otherwise check DOM if (this.table.columnVisibility) { const states = Object.keys(this.table.columnVisibility).map(index => ({ index: parseInt(index), visible: this.table.columnVisibility[index] !== false })); return states; } // Fallback to DOM inspection const states = []; const headers = this.table.thead?.querySelectorAll('th[data-column]') || []; headers.forEach((th, index) => { states.push({ index: index, visible: getComputedStyle(th).display !== 'none' }); }); return states; } /** * Apply column states */ applyColumnStates(states) { try { // Initialize columnVisibility if not exists if (!this.table.columnVisibility) { this.table.columnVisibility = {}; // Initialize all columns as visible first this.table.options.columns.forEach((column, index) => { this.table.columnVisibility[index] = true; }); } states.forEach(state => { const columnIndex = state.index; if (typeof columnIndex === 'number' && typeof state.visible === 'boolean') { this.table.columnVisibility[columnIndex] = state.visible; // Apply visibility immediately if (this.table.applyColumnVisibility) { this.table.applyColumnVisibility(columnIndex, state.visible); } } }); // Force apply all column visibility after data is loaded setTimeout(() => { if (this.table.applyAllColumnVisibility) { this.table.applyAllColumnVisibility(); } }, 200); } catch (error) { console.warn('Error applying column states:', error); } } /** * Apply filters from state */ applyFilters(filters) { try { // Try immediate application first const filterInputs = this.table.wrapper?.querySelectorAll('[data-filter]') || []; if (filterInputs.length > 0) { // Filters are ready, apply immediately filterInputs.forEach(input => { const filterKey = input.dataset.filter; if (filters[filterKey]) { input.value = filters[filterKey]; // Trigger change event to update filter state input.dispatchEvent(new Event('change', { bubbles: true })); } }); } else { // Filters not ready, use retry mechanism with shorter delays const maxRetries = 3; let retries = 0; const tryApplyFilters = () => { const filterInputs = this.table.wrapper?.querySelectorAll('[data-filter]') || []; if (filterInputs.length === 0 && retries < maxRetries) { retries++; setTimeout(tryApplyFilters, 50); // Reduced from 100ms to 50ms return; } if (filterInputs.length > 0) { filterInputs.forEach(input => { const filterKey = input.dataset.filter; if (filters[filterKey]) { input.value = filters[filterKey]; // Trigger change event to update filter state input.dispatchEvent(new Event('change', { bubbles: true })); } }); } }; tryApplyFilters(); } } catch (error) { console.warn('Error applying filters:', error); } } /** * Clear saved state */ clear() { try { localStorage.removeItem(this.storageKey); } catch (error) { console.warn('Failed to clear table state:', error); } } /** * Check if state saving is enabled */ isEnabled() { return this.table.options.stateSave === true; } } /** * DOM Utilities for ModernTable.js */ /** * Create element with attributes and content */ function createElement(tag, attributes = {}, content = '') { const element = document.createElement(tag); Object.entries(attributes).forEach(([key, value]) => { if (key === 'className') { element.className = value; } else if (key === 'innerHTML') { element.innerHTML = value; } else if (key === 'textContent') { element.textContent = value; } else { element.setAttribute(key, value); } }); if (content) { element.innerHTML = content; } return element; } /** * Add CSS classes */ function addClass(element, ...classes) { element.classList.add(...classes); } /** * Remove CSS classes */ function removeClass(element, ...classes) { element.classList.remove(...classes); } /** * Check if element has class */ function hasClass(element, className) { return element.classList.contains(className); } /** * Find element by selector */ function find(selector, context = document) { return context.querySelector(selector); } /** * Find all elements by selector */ function findAll(selector, context = document) { return Array.from(context.querySelectorAll(selector)); } /** * Detect CSS framework */ function detectFramework() { const body = document.body; const html = document.documentElement; // Check for Bootstrap if (find('.container, .container-fluid') || hasClass(body, 'bootstrap') || find('link[href*="bootstrap"]')) { return 'bootstrap'; } // Check for Tailwind if (hasClass(html, 'tailwind') || find('link[href*="tailwind"]') || find('[class*="bg-"], [class*="text-"], [class*="p-"], [class*="m-"]')) { return 'tailwind'; } // Check for Bulma if (find('.container, .column') || find('link[href*="bulma"]')) { return 'bulma'; } return 'none'; } /** * Get framework-specific classes */ function getFrameworkClasses(framework = null) { if (!framework) { framework = detectFramework(); } const classes = { bootstrap: { button: 'btn', buttonPrimary: 'btn btn-primary', buttonSecondary: 'btn btn-secondary', input: 'form-control', select: 'form-select', table: 'table', pagination: 'pagination', pageItem: 'page-item', pageLink: 'page-link' }, tailwind: { button: 'px-4 py-2 rounded', buttonPrimary: 'bg-blue-500 text-white px-4 py-2 rounded hover:bg-blue-600', buttonSecondary: 'bg-gray-500 text-white px-4 py-2 rounded hover:bg-gray-600', input: 'border border-gray-300 rounded px-3 py-2', select: 'border border-gray-300 rounded px-3 py-2', table: 'min-w-full divide-y divide-gray-200', pagination: 'flex space-x-1', pageItem: '', pageLink: 'px-3 py-2 border border-gray-300 rounded' }, bulma: { button: 'button', buttonPrimary: 'button is-primary', buttonSecondary: 'button is-light', input: 'input', select: 'select', table: 'table', pagination: 'pagination-list', pageItem: '', pageLink: 'pagination-link' }, none: { button: 'mt-button', buttonPrimary: 'mt-button mt-button-primary', buttonSecondary: 'mt-button mt-button-secondary', input: 'mt-input', select: 'mt-select', table: 'mt-table', pagination: 'mt-pagination', pageItem: 'mt-page-item', pageLink: 'mt-page-link' } }; return classes[framework] || classes.none; } /** * Performance utilities for ModernTable.js */ /** * Debounce function - delays execution until after delay */ function debounce(func, delay = 300) { let timeoutId; return function (...args) { clearTimeout(timeoutId); timeoutId = setTimeout(() => func.apply(this, args), delay); }; } /** * FilterPanel.js - Advanced filters component for ModernTable.js * Sesuai master plan: components/FilterPanel.js (3KB) */ class FilterPanel { constructor(table) { this.table = table; this.filters = {}; this.init(); } init() { if (this.table.options.filters && this.table.options.filters.length > 0) { this.createFilterPanel(); } } /** * Create filter panel */ createFilterPanel() { this.filtersContainer = createElement('div', { className: 'modern-table-filters mb-3 p-3 bg-body-secondary rounded border' }); const filtersRow = createElement('div', { className: 'd-flex flex-wrap gap-2 align-items-end' }); this.table.options.filters.forEach(filter => { // Create flex item with auto width based on content const col = createElement('div', { className: 'flex-shrink-0' }); this.createFilter(filter, col); filtersRow.appendChild(col); }); this.filtersContainer.appendChild(filtersRow); // Insert after toolbar if exists, otherwise before table if (this.table.toolbar) { this.table.toolbar.parentNode.insertBefore(this.filtersContainer, this.table.toolbar.nextSibling); } else { this.table.wrapper.insertBefore(this.filtersContainer, this.table.element); } } /** * Create individual filter */ createFilter(filter, container) { switch (filter.type) { case 'select': this.createSelectFilter(filter, container); break; case 'text': this.createTextFilter(filter, container); break; case 'date': this.createDateFilter(filter, container); break; case 'daterange': this.createDateRangeFilter(filter, container); break; case 'numberrange': this.createNumberRangeFilter(filter, container); break; case 'clear': this.createClearButton(filter, container); break; } } /** * Create select filter */ createSelectFilter(filter, container) { // Add label if (filter.label) { const label = createElement('label', { className: 'form-label small mb-1', textContent: filter.label }); container.appendChild(label); } const select = createElement('select', { className: 'form-select form-select-sm', 'data-filter': filter.column, style: 'min-width: 120px; width: auto;' }); filter.options.forEach(option => { const optionEl = createElement('option', { value: option.value, textContent: option.text }); select.appendChild(optionEl); }); select.addEventListener('change', (e) => { this.applyFilter(filter.column, e.target.value); }); container.appendChild(select); } /** * Create text filter */ createTextFilter(filter, container) { const input = createElement('input', { type: 'text', className: 'form-control form-control-sm', placeholder: filter.placeholder || `Filter ${filter.label}`, 'data-filter': filter.column }); // Debounce text input let timeout; input.addEventListener('input', (e) => { clearTimeout(timeout); timeout = setTimeout(() => { this.applyFilter(filter.column, e.target.value); }, 300); }); container.appendChild(input); } /** * Create single date filter */ createDateFilter(filter, container) { // Add label if (filter.label) { const label = createElement('label', { className: 'form-label small mb-1', textContent: filter.label }); container.appendChild(label); } const input = createElement('input', { type: 'date', className: 'form-control form-control-sm', placeholder: filter.placeholder || `Filter ${filter.label}`, 'data-filter': filter.column, style: 'width: 150px;' }); input.addEventListener('change', (e) => { // Smart date range logic: only trigger when end_date has value if (filter.column === 'start_date') { // Store start_date but don't trigger filter yet if (e.target.value) { this.filters[filter.column] = e.target.value; } else { delete this.filters[filter.column]; } // Only trigger if end_date also has value const endDateInput = document.querySelector('[data-filter="end_date"]'); if (endDateInput && endDateInput.value) { this.table.currentPage = 1; this.table.loadData(); } } else if (filter.column === 'end_date') { // Store end_date and trigger filter (regardless of start_date) if (e.target.value) { this.filters[filter.column] = e.target.value; } else { delete this.filters[filter.column]; } this.table.currentPage = 1; this.table.loadData(); } else { // For single date filters, apply immediately this.applyFilter(filter.column, e.target.value); } }); container.appendChild(input); } /** * Create date range filter */ createDateRangeFilter(filter, container) { // Add label if (filter.label) { const label = createElement('label', { className: 'form-label small mb-1', textContent: filter.label }); container.appendChild(label); } const wrapper = createElement('div', { className: 'd-flex gap-1' }); const fromInput = createElement('input', { type: 'date', className: 'form-control form-control-sm', placeholder: 'From', 'data-filter': `${filter.column}_from`, style: 'width: 140px;' }); const toInput = createElement('input', { type: 'date', className: 'form-control form-control-sm', placeholder: 'To', 'data-filter': `${filter.column}_to`, style: 'width: 140px;' }); fromInput.addEventListener('change', () => { this.applyDateRangeFilter(filter.column, fromInput.value, toInput.value); }); toInput.addEventListener('change', () => { this.applyDateRangeFilter(filter.column, fromInput.value, toInput.value); }); wrapper.appendChild(fromInput); wrapper.appendChild(toInput); container.appendChild(wrapper); } /** * Create number range filter */ createNumberRangeFilter(filter, container) { const wrapper = createElement('div', { className: 'd-flex gap-1' }); const minInput = createElement('input', { type: 'number', className: 'form-control form-control-sm', placeholder: `Min ${filter.label}`, min: filter.min || 0, max: filter.max || 999999, 'data-filter': `${filter.column}_min` }); const maxInput = createElement('input', { type: 'number', className: 'form-control form-control-sm', placeholder: `Max ${filter.label}`, min: filter.min || 0, max: filter.max || 999999, 'data-filter': `${filter.column}_max` }); minInput.addEventListener('change', () => { this.applyNumberRangeFilter(filter.column, minInput.value, maxInput.value); }); maxInput.addEventListener('change', () => { this.applyNumberRangeFilter(filter.column, minInput.value, maxInput.value); }); wrapper.appendChild(minInput); wrapper.appendChild(maxInput); container.appendChild(wrapper); } /** * Apply single filter */ applyFilter(column, value) { if (value && value.trim()) { this.filters[column] = value.trim(); } else { delete this.filters[column]; } this.table.currentPage = 1; // Save state after filter if (this.table.stateManager && this.table.stateManager.isEnabled()) { this.table.stateManager.save(); } this.table.loadData(); } /** * Apply date range filter */ applyDateRangeFilter(column, fromDate, toDate) { if (fromDate) { this.filters[`${column}_from`] = fromDate; } else { delete this.filters[`${column}_from`]; } if (toDate) { this.filters[`${column}_to`] = toDate; } else { delete this.filters[`${column}_to`]; } this.table.currentPage = 1; // Save state after filter if (this.table.stateManager && this.table.stateManager.isEnabled()) { this.table.stateManager.save(); } this.table.loadData(); } /** * Apply number range filter */ applyNumberRangeFilter(column, minValue, maxValue) { if (minValue && !isNaN(minValue)) { this.filters[`${column}_min`] = minValue; } else { delete this.filters[`${column}_min`]; } if (maxValue && !isNaN(maxValue)) { this.filters[`${column}_max`] = maxValue; } else { delete this.filters[`${column}_max`]; } this.table.currentPage = 1; // Save state after filter if (this.table.stateManager && this.table.stateManager.isEnabled()) { this.table.stateManager.save(); } this.table.loadData(); } /** * Get current filters */ getFilters() { return { ...this.filters }; } /** * Set filters from state and trigger reload */ setFilters(filters) { this.filters = { ...filters }; // Update filter inputs to match state Object.keys(filters).forEach(filterKey => { const input = find(`[data-filter="${filterKey}"]`, this.filtersContainer); if (input && filters[filterKey]) { input.value = filters[filterKey]; // Trigger change event to update UI if (input.tagName === 'SELECT') { input.dispatchEvent(new Event('change')); } } }); // Trigger reload to apply restored filters setTimeout(() => { this.table.loadData(); }, 100); } /** * Create clear button */ createClearButton(filter, container) { const button = createElement('button', { type: 'button', className: filter.className || 'btn btn-outline-danger btn-sm', innerHTML: `<i class="${filter.icon || 'fas fa-eraser'}"></i> ${filter.label || 'Clear All'}` }); button.addEventListener('click', (e) => { e.preventDefault(); if (filter.action && typeof filter.action === 'function') { filter.action(); } else { this.clearFilters(); } }); container.appendChild(button); } /** * Clear all filters */ clearFilters(skipReload = false) { this.filters = {}; // Reset all filter inputs const filterInputs = findAll('[data-filter]', this.filtersContainer); filterInputs.forEach(input => { if (input.tagName === 'SELECT') { input.selectedIndex = 0; } else { input.value = ''; } }); this.table.currentPage = 1; if (!skipReload) { this.table.loadData(); } } /** * Destroy filter panel */ destroy() { if (this.filtersContainer && this.filtersContainer.parentNode) { this.filtersContainer.parentNode.removeChild(this.filtersContainer); } } } /** * Export utilities for ModernTable.js */ /** * Download file helper */ function downloadFile(content, mimeType, filename) { const blob = new Blob([content], { type: mimeType }); const url = window.URL.createObjectURL(blob); const link = document.createElement('a'); link.href = url; link.download = filename; document.body.appendChild(link); link.click(); document.body.removeChild(link); window.URL.revokeObjectURL(url); } /** * Format data for print */ function formatPrint(data, title = 'Table Data') { let html = ` <!DOCTYPE html> <html> <head> <title>${title}</title> <style> body { font-family: Arial, sans-serif; margin: 20px; } h1 { color: #333; margin-bottom: 20px; } table { width: 100%; border-collapse: collapse; margin-top: 10px; } th, td { border: 1px solid #ddd; padding: 8px; text-align: left; } th { background-color: #f5f5f5; font-weight: bold; } tr:nth-child(even) { background-color: #f9f9f9; } @media print { body { margin: 0; } table { font-size: 12px; } } </style> </head> <body> <h1>${title}</h1> <table> `; // Add headers if (data.headers && data.headers.length > 0) { html += '<thead><tr>'; data.headers.forEach(header => { html += `<th>${escapeHtml(String(header))}</th>`; }); html += '</tr></thead>'; } // Add data rows html += '<tbody>'; data.rows.forEach(row => { html += '<tr>'; row.forEach(cell => { html += `<td>${escapeHtml(String(cell || ''))}</td>`; }); html += '</tr>'; }); html += '</tbody>'; html += ` </table> <p style="margin-top: 20px; font-size: 12px; color: #666;"> Generated on ${new Date().toLocaleString()} </p> </body> </html> `; return html; } /** * Copy data to clipboard */ async function copyToClipboard(data) { const text = formatClipboard(data); if (navigator.clipboard && window.isSecureContext) { try { await navigator.clipboard.writeText(text); return true; } catch (err) { console.warn('Clipboard API failed, using fallback'); } } // Fallback for older browsers return copyToClipboardFallback(text); } /** * Format data for clipboard (tab-separated) */ function formatClipboard(data) { const lines = []; // Add headers if (data.headers && data.headers.length > 0) { lines.push(data.headers.join('\t')); } // Add data rows data.rows.forEach(row => { lines.push(row.map(cell => String(cell || '')).join('\t')); }); return lines.join('\n'); } /** * Fallback clipboard copy for older browsers */ function copyToClipboardFallback(text) { const textarea = document.createElement('textarea'); textarea.value = text; textarea.style.position = 'fixed'; textarea.style.opacity = '0'; document.body.appendChild(textarea); textarea.focus(); textarea.select(); try { const successful = document.execCommand('copy'); document.body.removeChild(textarea); return successful; } catch (err) { document.body.removeChild(textarea); return false; } } /** * Escape HTML characters */ function escapeHtml(text) { return String(text) .replace(/&/g, '&amp;') .replace(/</g, '&lt;') .replace(/>/g, '&gt;') .replace(/"/g, '&quot;') .replace(/'/g, '&#x27;'); } /** * Show notification */ function showNotification(message, type = 'success') { // Create notification element const notification = document.createElement('div'); notification.className = `alert alert-${type} alert-dismissible fade show position-fixed`; notification.style.cssText = 'top: 20px; right: 20px; z-index: 9999; min-width: 300px;'; notification.innerHTML = ` ${message} <button type="button" class="btn-close" data-bs-dismiss="alert"></button> `; document.body.appendChild(notification); // Auto remove after 3 seconds setTimeout(() => { if (notification.parentNode) { notification.parentNode.removeChild(notification); } }, 3000); } /** * ExportPlugin.js - Export functionality plugin for ModernTable.js * Sesuai master plan: plugins/ExportPlugin.js (3KB) */ class ExportPlugin { constructor(table) { this.table = table; this.init(); } init() { // Plugin i