/** * DataTable Component * Sortable, paginated table with row actions and empty states * WCAG 3.0+ compliant with keyboard navigation, ARIA live regions, and focus management */ class DataTable { constructor(container, options = {}) { this.container = container; this.options = { columns: [], data: [], sortable: true, pagination: true, pageSize: 20, pageSizeOptions: [10, 20, 50, 100], onRowClick: null, rowActions: [], emptyText: 'No data available', emptyDescription: 'There are no items to display.', ariaLabel: 'Data Table', ariaLiveRegion: true, liveRegionPoliteness: 'polite', selectableRows: false, ...options }; this.state = { data: [], filteredData: [], sortColumn: null, sortDirection: 'asc', currentPage: 1, pageSize: this.options.pageSize, loading: false, selectedRows: [], currentRowIndex: 0 }; this.liveRegion = null; this.init(); } init() { this.render(); this.setData(this.options.data); } render() { this.container.innerHTML = `
${this.options.columns.map(col => this.renderHeader(col)).join('')} ${this.options.rowActions.length ? '' : ''}
Actions

Loading...

${this.options.pagination ? this.renderPagination() : ''} ${this.options.ariaLiveRegion ? `
` : ''}
`; this.liveRegion = this.container.querySelector('#table-live-region'); this.attachEvents(); } renderHeader(column) { const sortable = this.options.sortable && column.sortable !== false; const isSorted = this.state.sortColumn === column.key; const sortClass = isSorted ? this.state.sortDirection : ''; const sortDirection = isSorted ? this.state.sortDirection : 'none'; const ariaSort = isSorted ? this.state.sortDirection : undefined; return ` ${sortable ? ` ` : column.title} `; } renderRow(item, itemIndex, globalIndex) { const isSelected = this.state.selectedRows.includes(globalIndex); const ariaSelected = isSelected ? 'true' : 'false'; return ` ${this.options.selectableRows ? ` ` : ''} ${this.options.columns.map(col => this.renderCell(item, col, globalIndex)).join('')} ${this.options.rowActions.length ? this.renderRowActions(item, globalIndex) : ''} `; } renderCell(item, column, rowIndex) { let value = this.getNestedValue(item, column.key); if (column.formatter) { value = column.formatter(value, item); } else if (value === null || value === undefined) { value = '-'; } return `${value}`; } renderRowActions(item, index) { return `
${this.options.rowActions.map(action => ` `).join('')}
`; } renderPagination() { const totalPages = Math.ceil(this.state.filteredData.length / this.state.pageSize); const start = (this.state.currentPage - 1) * this.state.pageSize + 1; const end = Math.min(this.state.currentPage * this.state.pageSize, this.state.filteredData.length); return ` `; } renderPageButtons(totalPages) { if (totalPages <= 1) return ''; const buttons = []; const maxVisible = 5; let start = Math.max(1, this.state.currentPage - Math.floor(maxVisible / 2)); let end = Math.min(totalPages, start + maxVisible - 1); if (end - start + 1 < maxVisible) { start = Math.max(1, end - maxVisible + 1); } if (start > 1) { buttons.push(``); if (start > 2) buttons.push(`...`); } for (let i = start; i <= end; i++) { buttons.push(` `); } if (end < totalPages) { if (end < totalPages - 1) buttons.push(`...`); buttons.push(``); } return buttons.join(''); } renderEmpty() { return `

${this.options.emptyText}

${this.options.emptyDescription}

`; } getNestedValue(obj, path) { return path.split('.').reduce((acc, part) => acc && acc[part], obj); } attachEvents() { // Sort if (this.options.sortable) { this.container.querySelectorAll('th.sortable').forEach(th => { th.addEventListener('click', (e) => { const column = e.currentTarget.dataset.sort; this.handleSort(column); }); }); } // Row click if (this.options.onRowClick) { this.container.addEventListener('click', (e) => { const row = e.target.closest('tr[data-index]'); if (row && !e.target.closest('.row-actions')) { const index = parseInt(row.dataset.index); const item = this.getCurrentPageData()[index]; this.options.onRowClick(item); } }); } // Row actions this.container.addEventListener('click', (e) => { const btn = e.target.closest('button[data-action]'); if (btn) { const action = btn.dataset.action; const index = parseInt(btn.dataset.index); const item = this.getCurrentPageData()[index]; const actionConfig = this.options.rowActions.find(a => a.key === action); if (actionConfig && actionConfig.handler) { actionConfig.handler(item, index); } } }); // Pagination this.container.addEventListener('click', (e) => { const btn = e.target.closest('button[data-page]'); if (btn) { const page = btn.dataset.page; this.handlePageChange(page); } }); // Page size const pageSizeSelect = this.container.querySelector('#page-size'); if (pageSizeSelect) { pageSizeSelect.addEventListener('change', (e) => { this.state.pageSize = parseInt(e.target.value); this.state.currentPage = 1; this.refresh(); }); } // Checkbox selection if (this.options.selectableRows) { this.container.addEventListener('change', (e) => { if (e.target.type === 'checkbox' && e.target.dataset.selectIndex) { const rowIndex = parseInt(e.target.dataset.selectIndex); this.toggleRowSelection(rowIndex); } }); } // Keyboard navigation for table this.container.addEventListener('keydown', (e) => { this.handleTableKeyboard(e); }); } /** * Handle keyboard navigation within table * Arrow keys navigate between rows, Enter selects, Space toggles checkbox */ handleTableKeyboard(e) { const tbody = this.container.querySelector('#table-body'); if (!tbody) return; const rows = tbody.querySelectorAll('tr[role="row"]'); if (rows.length === 0) return; // Check if focus is on a row const currentRow = document.activeElement.closest('tr[role="row"]'); if (!currentRow) return; const currentGlobalIndex = parseInt(currentRow.dataset.index); switch (e.key) { case 'ArrowDown': e.preventDefault(); const nextIndex = Math.min(rows.length - 1, this.state.currentRowIndex + 1); this.navigateToRow(nextIndex); break; case 'ArrowUp': e.preventDefault(); const prevIndex = Math.max(0, this.state.currentRowIndex - 1); this.navigateToRow(prevIndex); break; case 'Enter': e.preventDefault(); if (this.options.onRowClick) { const item = this.getCurrentPageData()[this.state.currentRowIndex]; this.options.onRowClick(item); } break; case 'Space': if (this.options.selectableRows && currentRow.querySelector('input[type="checkbox"]')) { e.preventDefault(); const checkbox = currentRow.querySelector('input[type="checkbox"]'); checkbox.checked = !checkbox.checked; this.toggleRowSelection(currentGlobalIndex); } break; case 'Home': e.preventDefault(); this.navigateToRow(0); break; case 'End': e.preventDefault(); this.navigateToRow(rows.length - 1); break; } } /** * Navigate focus to a specific row */ navigateToRow(rowIndex) { const tbody = this.container.querySelector('#table-body'); const rows = tbody.querySelectorAll('tr[role="row"]'); if (rowIndex < 0 || rowIndex >= rows.length) return; // Update current row tabindex rows.forEach((row, idx) => { row.setAttribute('tabindex', idx === rowIndex ? '0' : '-1'); }); this.state.currentRowIndex = rowIndex; rows[rowIndex].focus(); // Announce row change to screen readers this.announce(`Row ${rowIndex + 1}: ${this.getRowText(rows[rowIndex])}`); } /** * Get text content of a row for screen reader announcement */ getRowText(row) { return row.textContent.trim().substring(0, 100); } /** * Toggle row selection state */ toggleRowSelection(rowIndex) { const index = this.state.selectedRows.indexOf(rowIndex); if (index === -1) { this.state.selectedRows.push(rowIndex); } else { this.state.selectedRows.splice(index, 1); } this.refresh(); this.announce(`Row ${rowIndex + 1} ${index === -1 ? 'selected' : 'deselected'}`); } /** * Announce message to screen readers via live region */ announce(message) { if (this.liveRegion) { this.liveRegion.textContent = ''; setTimeout(() => { this.liveRegion.textContent = message; }, 100); } } handleSort(column) { if (this.state.sortColumn === column) { this.state.sortDirection = this.state.sortDirection === 'asc' ? 'desc' : 'asc'; } else { this.state.sortColumn = column; this.state.sortDirection = 'asc'; } this.sortData(); this.refresh(); } handlePageChange(page) { const totalPages = Math.ceil(this.state.filteredData.length / this.state.pageSize); if (page === 'prev') { this.state.currentPage = Math.max(1, this.state.currentPage - 1); } else if (page === 'next') { this.state.currentPage = Math.min(totalPages, this.state.currentPage + 1); } else { this.state.currentPage = parseInt(page); } this.refresh(); } sortData() { if (!this.state.sortColumn) return; const column = this.options.columns.find(c => c.key === this.state.sortColumn); if (!column) return; this.state.filteredData.sort((a, b) => { let aVal = this.getNestedValue(a, this.state.sortColumn); let bVal = this.getNestedValue(b, this.state.sortColumn); if (column.sortFn) { return this.state.sortDirection === 'asc' ? column.sortFn(aVal, bVal) : column.sortFn(bVal, aVal); } if (typeof aVal === 'string') { aVal = aVal.toLowerCase(); bVal = bVal.toLowerCase(); } if (aVal < bVal) return this.state.sortDirection === 'asc' ? -1 : 1; if (aVal > bVal) return this.state.sortDirection === 'asc' ? 1 : -1; return 0; }); } getCurrentPageData() { const start = (this.state.currentPage - 1) * this.state.pageSize; const end = start + this.state.pageSize; return this.state.filteredData.slice(start, end); } refresh() { const tbody = this.container.querySelector('#table-body'); const data = this.getCurrentPageData(); const pageStart = (this.state.currentPage - 1) * this.state.pageSize; if (data.length === 0) { tbody.innerHTML = this.renderEmpty(); } else { tbody.innerHTML = data.map((item, index) => { const globalIndex = pageStart + index; return this.renderRow(item, index, globalIndex); }).join(''); } // Update header sort indicators this.container.querySelectorAll('th.sortable').forEach(th => { th.classList.remove('asc', 'desc'); if (th.dataset.sort === this.state.sortColumn) { th.classList.add(this.state.sortDirection); } }); // Update pagination if (this.options.pagination) { const paginationContainer = this.container.querySelector('.table-pagination'); if (paginationContainer) { paginationContainer.outerHTML = this.renderPagination(); // Restore focus to pagination if it was focused if (document.activeElement && document.activeElement.dataset && document.activeElement.dataset.page && this.state.currentPage) { const currentBtn = paginationContainer.querySelector(`[data-page="${this.state.currentPage}"]`); if (currentBtn) currentBtn.focus(); } } } // Announce table updates to screen readers if (data.length === 0) { this.announce('No data available'); } else if (this.state.sortColumn) { this.announce(`Table sorted by ${this.state.sortColumn}, ${this.state.sortDirection} order`); } } setData(data) { this.state.data = [...data]; this.state.filteredData = [...data]; this.state.currentPage = 1; if (this.state.sortColumn) { this.sortData(); } this.refresh(); } filter(fn) { if (fn) { this.state.filteredData = this.state.data.filter(fn); } else { this.state.filteredData = [...this.state.data]; } this.state.currentPage = 1; this.refresh(); } setLoading(loading) { this.state.loading = loading; if (loading) { const tbody = this.container.querySelector('#table-body'); tbody.innerHTML = `

Loading...

`; } } /** * Enable virtual scrolling for large tables (performance + accessibility) * Renders only visible rows while maintaining keyboard navigation */ enableVirtualScroll(scrollHeight = 400) { const tableContainer = this.container.querySelector('.table-container'); if (!tableContainer) return; tableContainer.style.cssText += ` max-height: ${scrollHeight}px; overflow-y: auto; position: relative; `; // Announce virtual scroll mode to screen readers this.announce('Table enabled with virtual scrolling for improved performance'); } } window.DataTable = DataTable;